From 303d0eedf5346ccfabfb4137c1c981d184f511f9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 4 Oct 2023 20:58:22 +0400 Subject: [PATCH 01/80] core: merge new contacts with existing contacts and group members (#3173) --- docs/rfcs/2023-09-29-merge-scenarios.md | 99 +++++ simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 272 ++++++++++---- src/Simplex/Chat/Controller.hs | 8 +- .../Migrations/M20231002_conn_initiated.hs | 28 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 4 + src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 41 ++- src/Simplex/Chat/Store/Groups.hs | 290 +++++++++------ src/Simplex/Chat/Store/Messages.hs | 2 +- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 14 +- src/Simplex/Chat/Types.hs | 19 +- src/Simplex/Chat/View.hs | 16 +- tests/ChatTests/Direct.hs | 132 +++++-- tests/ChatTests/Groups.hs | 347 +++++++++++++++++- tests/ChatTests/Profiles.hs | 34 ++ tests/ChatTests/Utils.hs | 35 +- 19 files changed, 1087 insertions(+), 265 deletions(-) create mode 100644 docs/rfcs/2023-09-29-merge-scenarios.md create mode 100644 src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs diff --git a/docs/rfcs/2023-09-29-merge-scenarios.md b/docs/rfcs/2023-09-29-merge-scenarios.md new file mode 100644 index 0000000000..42a7425f05 --- /dev/null +++ b/docs/rfcs/2023-09-29-merge-scenarios.md @@ -0,0 +1,99 @@ +# Merge scenarios + +## Problem + +Chat client allows multiple contact and group members records referring to the same "identity" be disassociated from one another. In some cases initially there is knowledge about the fact, and in some other cases it could be established via probe mechanism. + +There are cases already addressing this problem: +- Contact merge or contact and group member merge (depending on creation of direct connection between members) when new member joins group, via probe mechanism. +- Contact merge when connecting via group link. +- Contact and group member association when sending direct message to a new contact. +- Existing / deleted contact being preserved when receiving invitation direct message (XGrpDirectInv) from a group member. +- Repeat contact requests to the already connected contact being prohibited; repeat contact requests being squashed on the receiving side (XContactId mechanism). + +Cases ignoring this problem: +- Duplicate contacts on repeat connections via invitation links. +- Duplicate contacts on repeat connection via contact request, when the existing contact was not created via a contact request to the same address (it could be created via any other means - via invitation link, via request to an old address, via group member). +- Contact and group member records (possibly for many groups) not being merged when contact connects and group member records already exist. +- Group member records in different groups not being merged if contact doesn't exist. +- Duplicate contacts, or contact and group member records not being merged when connecting via contact address present in contact/group member profile. + +This problem is a direct consequence of lack of user identity in the platform, and in some cases we even consider it a feature. For example, duplicate contacts via repeat connections can be used for having conversation scopes. Though in general it seems to bring more confusion. It also limits some interactions, such as sending direct message to group members, or viewing a list of groups contact is member of (not implemented). + +On the other hand, solving all these cases reduces the privacy of the main profile, since the client cooperates with other probing clients blindly. To keep this property, we can add an opt-out "Merge contacts" client setting which would affect existing and new probe mechanisms. (It would act as an Incognito mode currently does - launch probes w/t launching probe hashes, and never confirm received probes) We can also ignore it, since this can be worked around via Incognito profiles or multiple user profiles. + +There is one more problem that could be addressed in the same scope - repeat group join via group link fails if the contact with host wasn't deleted. This happens due to group links re-using contact address machinery together with prohibiting repeat connections. This could be treated in a similar way to how it is treated for contact addresses: instead of simply prohibiting repeat connection, if group exists, it would be opened; if group doesn't exist, client would send host a request to re-invite them. + +## Solution + +### Duplicate contacts on repeat connections via invitation links + +Can be solved by probing contacts. + +Check connection with oneself and ask for confirmation. + +### Duplicate contacts on repeat connection via contact request + +Can be solved by probing contacts. + +Special case - records can be directly associated w/t probing when address is known in a contact / group member profile, see below. + +### Contact and group member records not merged when contact connects and group member records already exist + +Can be solved by probing group members. + +Send probe hashes to all viable group members? Or should group member records already have been merged between each other? (see below) + +In all cases above - who should initiate probing? It doesn't matter much, but possibly the contact that started connection. + +### Group member records in different groups not being merged if contact doesn't exist + +Currently multiple group members share the same "identity" by being associated to the same contact. However, it is allowed to have a group member record not associated contact. It can happen if group member's associated contact is deleted, or, with the latest changes that enable skipping creation of direct connections between group members, it could never exist in the first place. + +Can be solved by probing group members, merge could be done in one of the following ways: +- Have surrogate contact records always associated to group member records, not available for use as regular contacts and using group member connections for probing. +- Merge on the level of contact profiles. + +The latter seems more straightforward. + +Some more factors to consider: +- Group member record may have both associated contact to probe via, and matching group member records in other groups. +- Matching group member records in other groups may have associated contact records, which in turn may have associated contact records different from contact record associated to group member in question. +- Client to which probes are sent may have contact record deleted, but have group member connection - in this case merge would be possible only if probe is sent to group member. +- Member connections shouldn't be merged, because generally they're established via hosts, and hosts of different groups may have had different level of trust. + +Probably the solution is to: +- send probe to associated contact if it exists (implemented currently) +- if associated contact doesn't exist send probe to group member (not implemented?) +- send probe hashes to all matching contacts (implemented) +- send probe hashes to all matching group members in other groups that don't have associated contact records (not implemented) +- merge all contacts that confirmed (currently only the first confirming contact is merged) +- merge all group members that confirmed (not implemented, merge profiles?) + +Check: if both group member and associated confirm probe, will they be properly merged? + +### Connecting via profile address + +Having contact address in profile is not proof that it belongs that user (malicious client can put arbitrary address in profile), so it shouldn't be used to directly associate contact or group member records. + +When connecting via contact address, having it associated with a contact record can be used as a sufficient condition to send probe hash, even if profile doesn't match. (index on contact_profiles.contact_link, lookup contact by contact_profile_id) + +To consider: + +Currently group member address is shown even if direct messages are prohibited in group. There're two reasons this preference is usually enabled in a group: +- to prevent abuse (in public groups), +- to prevent members from direct communication. + +Member address being shown regardless of this preference undermines the second use case. + +### Repeat group join via group link + +**If group still exists:** + +Open group. + +How to check for group existence? Currently we save group_link_id on host contact's connection. It may have been deleted by the time of repeated connection via group link. Probably we should also save group_link_id or even the full contact address on the group record itself. + +**If group doesn't exist:** + +For group links allow repeat contact request even if host contact exists (unlike for regular contact requests). diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 0b613310f7..30c4c62dc1 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -114,6 +114,7 @@ library Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes Simplex.Chat.Migrations.M20230926_contact_status + Simplex.Chat.Migrations.M20231002_conn_initiated Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5d370ee651..32d59c9cc5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,6 +39,7 @@ import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, foldl', isSuffixOf, partition, sortOn) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -212,7 +213,8 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + contactMergeEnabled <- newTVarIO True + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile, contactMergeEnabled} where configServers :: DefaultAgentServers configServers = @@ -489,6 +491,9 @@ processChatCommand = \case APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ + SetContactMergeEnabled onOff -> do + asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) + ok_ APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -2667,6 +2672,7 @@ cleanupManager = do forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay cleanupMessages `catchChatError` (toView . CRChatError Nothing) + cleanupProbes `catchChatError` (toView . CRChatError Nothing) liftIO $ threadDelay' $ diffToMicroseconds interval where runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CRChatError Nothing) $ do @@ -2694,6 +2700,10 @@ cleanupManager = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (- (30 * nominalDay)) ts withStoreCtx' (Just "cleanupManager, deleteOldMessages") (`deleteOldMessages` cutoffTs) + cleanupProbes = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (- (14 * nominalDay)) ts + withStore' (`deleteOldProbes` cutoffTs) startProximateTimedItemThread :: ChatMonad m => User -> (ChatRef, ChatItemId) -> UTCTime -> m () startProximateTimedItemThread user itemRef deleteAt = do @@ -2976,7 +2986,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId _ connInfo -> do -- [incognito] send saved profile @@ -3040,9 +3050,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XInfo p -> xInfo ct' p XDirectDel -> xDirectDel ct' msg msgMeta XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta - XInfoProbe probe -> xInfoProbe (CGMContact ct') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash - XInfoProbeOk probe -> xInfoProbeOk ct' probe + XInfoProbe probe -> xInfoProbe (COMContact ct') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMContact ct') probe XCallInv callId invitation -> xCallInv ct' callId invitation msg msgMeta XCallOffer callId offer -> xCallOffer ct' callId offer msg msgMeta XCallAnswer callId answer -> xCallAnswer ct' callId answer msg msgMeta @@ -3095,7 +3105,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do whenUserNtfs user $ do setActive $ ActiveC c showToast (c <> "> ") "connected" - forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct + when (contactConnInitiated conn) $ do + probeMatchingContactsAndMembers ct (contactConnIncognito ct) + withStore' $ \db -> resetContactConnInitiated db user conn forM_ viaUserContactLink $ \userContactLinkId -> withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_, gLinkMemRole) -> do @@ -3113,7 +3125,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito SENT msgId -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId @@ -3281,12 +3293,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memberCategory m == GCPreMember) $ probeMatchingMemberContact gInfo m connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn = Connection {connStatus}} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do @@ -3314,9 +3326,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XGrpDel -> xGrpDel gInfo m' msg msgMeta XGrpInfo p' -> xGrpInfo gInfo m' p' msg msgMeta XGrpDirectInv connReq mContent_ -> canSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg msgMeta - XInfoProbe probe -> xInfoProbe (CGMGroupMember gInfo m') probe - -- XInfoProbeCheck -- TODO merge members? - -- XInfoProbeOk -- TODO merge members? + XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -3679,45 +3691,56 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContacts :: Contact -> IncognitoEnabled -> m () - probeMatchingContacts ct connectedIncognito = do + probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> m () + probeMatchingContactsAndMembers ct connectedIncognito = do gVar <- asks idsDrg - if connectedIncognito - then sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) - else do - (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId (CGMContact ct) + contactMerge <- readTVarIO =<< asks contactMergeEnabled + if contactMerge && not connectedIncognito + then do + (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId (COMContact ct) + -- ! when making changes to probe-and-merge mechanism, + -- ! test scenario in which recipient receives probe after probe hashes (not covered in tests): + -- sendProbe -> sendProbeHashes (currently) + -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- withStore' $ \db -> getMatchingContacts db user ct - sendProbeHashes cs probe probeId + cs <- map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) + sendProbeHashes (cs <> ms) probe probeId + else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () sendProbe probe = void . sendDirectContactMessage ct $ XInfoProbe probe - probeMatchingMemberContact :: GroupInfo -> GroupMember -> IncognitoEnabled -> m () - probeMatchingMemberContact _ GroupMember {activeConn = Nothing} _ = pure () - probeMatchingMemberContact g m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do + probeMatchingMemberContact :: GroupMember -> IncognitoEnabled -> m () + probeMatchingMemberContact GroupMember {activeConn = Nothing} _ = pure () + probeMatchingMemberContact m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do gVar <- asks idsDrg - if connectedIncognito - then sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) - else do - (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ CGMGroupMember g m + contactMerge <- readTVarIO =<< asks contactMergeEnabled + if contactMerge && not connectedIncognito + then do + (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ COMGroupMember m sendProbe probe - cs <- withStore' $ \db -> getMatchingMemberContacts db user m + cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db user m) sendProbeHashes cs probe probeId + else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () sendProbe probe = void $ sendDirectMessage conn (XInfoProbe probe) (GroupId groupId) - -- TODO currently we only send probe hashes to contacts - sendProbeHashes :: [Contact] -> Probe -> Int64 -> m () - sendProbeHashes cs probe probeId = - forM_ cs $ \c -> sendProbeHash c `catchChatError` \_ -> pure () + sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> m () + sendProbeHashes cgms probe probeId = + forM_ cgms $ \cgm -> sendProbeHash cgm `catchChatError` \_ -> pure () where probeHash = ProbeHash $ C.sha256Hash (unProbe probe) - sendProbeHash :: Contact -> m () - sendProbeHash c = do + sendProbeHash :: ContactOrMember -> m () + sendProbeHash cgm@(COMContact c) = do void . sendDirectContactMessage c $ XInfoProbeCheck probeHash - withStore' $ \db -> createSentProbeHash db userId probeId $ CGMContact c + withStore' $ \db -> createSentProbeHash db userId probeId cgm + sendProbeHash (COMGroupMember GroupMember {activeConn = Nothing}) = pure () + sendProbeHash cgm@(COMGroupMember m@GroupMember {groupId, activeConn = Just conn}) = + when (memberCurrent m) $ do + void $ sendDirectMessage conn (XInfoProbeCheck probeHash) (GroupId groupId) + withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> m () messageWarning = toView . CRMessageError user "warning" @@ -4303,48 +4326,100 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (_, param) = groupFeatureState p createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param) Nothing - xInfoProbe :: ContactOrGroupMember -> Probe -> m () - xInfoProbe cgm2 probe = + xInfoProbe :: ContactOrMember -> Probe -> m () + xInfoProbe cgm2 probe = do + contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito - unless (contactOrGroupMemberIncognito cgm2) $ do - r <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe - forM_ r $ \case - CGMContact c1 -> probeMatch c1 cgm2 probe - CGMGroupMember _ _ -> messageWarning "xInfoProbe ignored: matched member (no probe hashes sent to members)" + when (contactMerge && not (contactOrMemberIncognito cgm2)) $ do + cgm1s <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe + let cgm1s' = filter (not . contactOrMemberIncognito) cgm1s + probeMatches cgm1s' cgm2 + where + probeMatches :: [ContactOrMember] -> ContactOrMember -> m () + probeMatches [] _ = pure () + probeMatches (cgm1' : cgm1s') cgm2' = do + cgm2''_ <- probeMatch cgm1' cgm2' probe `catchChatError` \_ -> pure (Just cgm2') + let cgm2'' = fromMaybe cgm2' cgm2''_ + probeMatches cgm1s' cgm2'' - -- TODO currently we send probe hashes only to contacts - xInfoProbeCheck :: Contact -> ProbeHash -> m () - xInfoProbeCheck c1 probeHash = + xInfoProbeCheck :: ContactOrMember -> ProbeHash -> m () + xInfoProbeCheck cgm1 probeHash = do + contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito - unless (contactConnIncognito c1) $ do - r <- withStore' $ \db -> matchReceivedProbeHash db user (CGMContact c1) probeHash - forM_ r . uncurry $ probeMatch c1 + when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do + cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db user cgm1 probeHash + forM_ cgm2Probe_ $ \(cgm2, probe) -> + unless (contactOrMemberIncognito cgm2) $ + void $ probeMatch cgm1 cgm2 probe - probeMatch :: Contact -> ContactOrGroupMember -> Probe -> m () - probeMatch c1@Contact {contactId = cId1, profile = p1} cgm2 probe = - case cgm2 of - CGMContact c2@Contact {contactId = cId2, profile = p2} - | cId1 /= cId2 && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - mergeContacts c1 c2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" - CGMGroupMember g m2@GroupMember {memberProfile = p2, memberContactId} - | isNothing memberContactId && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - connectContactToMember c1 g m2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" + probeMatch :: ContactOrMember -> ContactOrMember -> Probe -> m (Maybe ContactOrMember) + probeMatch cgm1 cgm2 probe = + case cgm1 of + COMContact c1@Contact {contactId = cId1, profile = p1} -> + case cgm2 of + COMContact c2@Contact {contactId = cId2, profile = p2} + | cId1 /= cId2 && profilesMatch p1 p2 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> mergeContacts c1 c2 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing + COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} + | profilesMatch p1 p2 -> case memberContactId of + Nothing -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> associateMemberAndContact c1 m2 + Just mCtId + | mCtId /= cId1 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + mCt <- withStore $ \db -> getContact db user mCtId + COMContact <$$> mergeContacts c1 mCt + | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing + | otherwise -> messageWarning "probeMatch ignored: profiles don't match" >> pure Nothing + COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing + COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId, activeConn = Just conn} -> + case cgm2 of + COMContact c2@Contact {contactId = cId2, profile = p2} + | memberCurrent m1 && profilesMatch p1 p2 -> case memberContactId of + Nothing -> do + void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + COMContact <$$> associateMemberAndContact c2 m1 + Just mCtId + | mCtId /= cId2 -> do + void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + mCt <- withStore $ \db -> getContact db user mCtId + COMContact <$$> mergeContacts c2 mCt + | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member not current" >> pure Nothing + COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing - -- TODO currently we send probe hashes only to contacts - xInfoProbeOk :: Contact -> Probe -> m () - xInfoProbeOk c1@Contact {contactId = cId1} probe = - withStore' (\db -> matchSentProbe db user (CGMContact c1) probe) >>= \case - Just (CGMContact c2@Contact {contactId = cId2}) - | cId1 /= cId2 -> mergeContacts c1 c2 - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" - Just (CGMGroupMember g m2@GroupMember {memberContactId}) - | isNothing memberContactId -> connectContactToMember c1 g m2 - | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" - _ -> pure () + xInfoProbeOk :: ContactOrMember -> Probe -> m () + xInfoProbeOk cgm1 probe = do + cgm2 <- withStore' $ \db -> matchSentProbe db user cgm1 probe + case cgm1 of + COMContact c1@Contact {contactId = cId1} -> + case cgm2 of + Just (COMContact c2@Contact {contactId = cId2}) + | cId1 /= cId2 -> void $ mergeContacts c1 c2 + | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMGroupMember m2@GroupMember {memberContactId}) -> + case memberContactId of + Nothing -> void $ associateMemberAndContact c1 m2 + Just mCtId + | mCtId /= cId1 -> do + mCt <- withStore $ \db -> getContact db user mCtId + void $ mergeContacts c1 mCt + | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + _ -> pure () + COMGroupMember m1@GroupMember {memberContactId} -> + case cgm2 of + Just (COMContact c2@Contact {contactId = cId2}) -> case memberContactId of + Nothing -> void $ associateMemberAndContact c2 m1 + Just mCtId + | mCtId /= cId2 -> do + mCt <- withStore $ \db -> getContact db user mCtId + void $ mergeContacts c2 mCt + | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMGroupMember _) -> messageWarning "xInfoProbeOk ignored: members are not matched with members" + _ -> pure () -- to party accepting call xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m () @@ -4451,15 +4526,53 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do msgCallStateError eventName Call {callState} = messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) - mergeContacts :: Contact -> Contact -> m () + mergeContacts :: Contact -> Contact -> m (Maybe Contact) mergeContacts c1 c2 = do - withStore' $ \db -> mergeContactRecords db userId c1 c2 - toView $ CRContactsMerged user c1 c2 + let Contact {localDisplayName = cLDN1, profile = LocalProfile {displayName}} = c1 + Contact {localDisplayName = cLDN2} = c2 + case (suffixOrd displayName cLDN1, suffixOrd displayName cLDN2) of + (Just cOrd1, Just cOrd2) + | cOrd1 < cOrd2 -> merge c1 c2 + | cOrd2 < cOrd1 -> merge c2 c1 + | otherwise -> pure Nothing + _ -> pure Nothing + where + merge c1' c2' = do + c2'' <- withStore $ \db -> mergeContactRecords db user c1' c2' + toView $ CRContactsMerged user c1' c2' c2'' + pure $ Just c2'' - connectContactToMember :: Contact -> GroupInfo -> GroupMember -> m () - connectContactToMember c1 g m2 = do - withStore' $ \db -> updateMemberContact db user c1 m2 - toView $ CRMemberContactConnected user c1 g m2 + associateMemberAndContact :: Contact -> GroupMember -> m (Maybe Contact) + associateMemberAndContact c m = do + let Contact {localDisplayName = cLDN, profile = LocalProfile {displayName}} = c + GroupMember {localDisplayName = mLDN} = m + case (suffixOrd displayName cLDN, suffixOrd displayName mLDN) of + (Just cOrd, Just mOrd) + | cOrd < mOrd -> Just <$> associateMemberWithContact c m + | mOrd < cOrd -> Just <$> associateContactWithMember m c + | otherwise -> pure Nothing + _ -> pure Nothing + + suffixOrd :: ContactName -> ContactName -> Maybe Int + suffixOrd displayName localDisplayName + | localDisplayName == displayName = Just 0 + | otherwise = case T.stripPrefix (displayName <> "_") localDisplayName of + Just suffix -> readMaybe $ T.unpack suffix + Nothing -> Nothing + + associateMemberWithContact :: Contact -> GroupMember -> m Contact + associateMemberWithContact c1 m2@GroupMember {groupId} = do + withStore' $ \db -> associateMemberWithContactRecord db user c1 m2 + g <- withStore $ \db -> getGroupInfo db user groupId + toView $ CRContactAndMemberAssociated user c1 g m2 c1 + pure c1 + + associateContactWithMember :: GroupMember -> Contact -> m Contact + associateContactWithMember m1@GroupMember {groupId} c2 = do + c2' <- withStore $ \db -> associateContactWithMemberRecord db user m1 c2 + g <- withStore $ \db -> getGroupInfo db user groupId + toView $ CRContactAndMemberAssociated user c2 g m1 c2' + pure c2' saveConnInfo :: Connection -> ConnInfo -> m Connection saveConnInfo activeConn connInfo = do @@ -5388,6 +5501,7 @@ chatCommandP = ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), + "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 61840b8e84..d859231faa 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -191,7 +191,8 @@ data ChatController = ChatController showLiveItems :: TVar Bool, userXFTPFileConfig :: TVar (Maybe XFTPFileConfig), tempDirectory :: TVar (Maybe FilePath), - logFilePath :: Maybe FilePath + logFilePath :: Maybe FilePath, + contactMergeEnabled :: TVar Bool } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase @@ -230,6 +231,7 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) + | SetContactMergeEnabled Bool | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -490,7 +492,7 @@ data ChatResponse | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} + | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} @@ -562,7 +564,7 @@ data ChatResponse | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} - | CRMemberContactConnected {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} diff --git a/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs b/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs new file mode 100644 index 0000000000..a0f6009af2 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231002_conn_initiated where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231002_conn_initiated :: Query +m20231002_conn_initiated = + [sql| +ALTER TABLE connections ADD COLUMN contact_conn_initiated INTEGER NOT NULL DEFAULT 0; + +UPDATE connections SET conn_req_inv = NULL WHERE conn_status IN ('ready', 'deleted'); + +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +|] + +down_m20231002_conn_initiated :: Query +down_m20231002_conn_initiated = + [sql| +DROP INDEX idx_sent_probes_created_at; +DROP INDEX idx_sent_probe_hashes_created_at; +DROP INDEX idx_received_probes_created_at; + +ALTER TABLE connections DROP COLUMN contact_conn_initiated; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 65ceb7d19b..e88d83e42d 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -264,6 +264,7 @@ CREATE TABLE connections( peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, + contact_conn_initiated INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -732,3 +733,6 @@ CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); CREATE INDEX idx_received_probes_probe ON received_probes(probe); CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 93f3349ca2..383db3c59c 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -58,7 +58,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 886c735053..20b81def8f 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -62,6 +62,7 @@ module Simplex.Chat.Store.Direct updateConnectionStatus, updateContactSettings, setConnConnReqInv, + resetContactConnInitiated, ) where @@ -126,11 +127,11 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_status, conn_type, + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) + ((userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -151,7 +152,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -174,13 +175,14 @@ createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation - createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile + let contactConnInitiated = pccConnStatus == ConnNew DB.execute db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?) + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) + (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -508,7 +510,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -672,7 +674,7 @@ getContact_ db user@User {userId} contactId deleted = ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -722,7 +724,7 @@ getContactConnections db userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id @@ -739,7 +741,7 @@ getConnectionById db User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? @@ -773,7 +775,11 @@ getConnectionsContacts db agentConnIds = do updateConnectionStatus :: DB.Connection -> Connection -> ConnStatus -> IO () updateConnectionStatus db Connection {connId} connStatus = do currentTs <- getCurrentTime - DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) + if connStatus == ConnReady + then + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) + else + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} = @@ -790,3 +796,16 @@ setConnConnReqInv db User {userId} connId connReq = do WHERE user_id = ? AND connection_id = ? |] (connReq, updatedAt, userId, connId) + +resetContactConnInitiated :: DB.Connection -> User -> Connection -> IO () +resetContactConnInitiated db User {userId} Connection {connId} = do + updatedAt <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections + SET contact_conn_initiated = 0, updated_at = ? + WHERE user_id = ? AND connection_id = ? + |] + (updatedAt, userId, connId) + diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e72ca8e8ca..f6a4233c1f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -77,6 +77,7 @@ module Simplex.Chat.Store.Groups getViaGroupMember, getViaGroupContact, getMatchingContacts, + getMatchingMembers, getMatchingMemberContacts, createSentProbe, createSentProbeHash, @@ -84,7 +85,9 @@ module Simplex.Chat.Store.Groups matchReceivedProbeHash, matchSentProbe, mergeContactRecords, - updateMemberContact, + associateMemberWithContactRecord, + associateContactWithMemberRecord, + deleteOldProbes, updateGroupSettings, getXGrpMemIntroContDirect, getXGrpMemIntroContGroup, @@ -105,7 +108,7 @@ import Crypto.Random (ChaChaDRG) import Data.Either (rights) import Data.Int (Int64) import Data.List (sortOn) -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe, isNothing, catMaybes, isJust) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) @@ -169,7 +172,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -251,7 +254,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -544,7 +547,7 @@ groupMemberQuery = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -703,7 +706,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -1013,7 +1016,7 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id @@ -1047,7 +1050,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id @@ -1176,13 +1179,32 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro AND p.display_name = ? AND p.full_name = ? |] +getMatchingMembers :: DB.Connection -> User -> Contact -> IO [GroupMember] +getMatchingMembers db user@User {userId} Contact {profile = LocalProfile {displayName, fullName, image}} = do + memberIds <- + map fromOnly <$> case image of + Just img -> DB.query db (q <> " AND p.image = ?") (userId, GCUserMember, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, GCUserMember, displayName, fullName) + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + where + -- only match with members without associated contact + q = + [sql| + SELECT m.group_member_id + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE m.user_id = ? AND m.contact_id IS NULL + AND m.member_category != ? + AND p.display_name = ? AND p.full_name = ? + |] + getMatchingMemberContacts :: DB.Connection -> User -> GroupMember -> IO [Contact] getMatchingMemberContacts _ _ GroupMember {memberContactId = Just _} = pure [] getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of - Just img -> DB.query db (q <> " AND p.image = ?") (userId, displayName, fullName, img) - Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, displayName, fullName) + Just img -> DB.query db (q <> " AND p.image = ?") (userId, CSActive, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, CSActive, displayName, fullName) rights <$> mapM (runExceptT . getContact db user) contactIds where q = @@ -1191,55 +1213,54 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? - AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND p.display_name = ? AND p.full_name = ? |] -createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> ContactOrGroupMember -> ExceptT StoreError IO (Probe, Int64) +createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> ContactOrMember -> ExceptT StoreError IO (Probe, Int64) createSentProbe db gVar userId to = createWithRandomBytes 32 gVar $ \probe -> do currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds to + let (ctId, gmId) = contactOrMemberIds to DB.execute db "INSERT INTO sent_probes (contact_id, group_member_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probe, userId, currentTs, currentTs) - (Probe probe,) <$> insertedRowId db + (Probe probe,) <$> insertedRowId db -createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrGroupMember -> IO () +createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrMember -> IO () createSentProbeHash db userId probeId to = do currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds to + let (ctId, gmId) = contactOrMemberIds to DB.execute db "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, group_member_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (probeId, ctId, gmId, userId, currentTs, currentTs) -matchReceivedProbe :: DB.Connection -> User -> ContactOrGroupMember -> Probe -> IO (Maybe ContactOrGroupMember) +matchReceivedProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] matchReceivedProbe db user@User {userId} from (Probe probe) = do let probeHash = C.sha256Hash probe cgmIds <- - maybeFirstRow id $ - DB.query - db - [sql| - SELECT r.contact_id, g.group_id, r.group_member_id - FROM received_probes r - LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 - LEFT JOIN group_members m ON r.group_member_id = m.group_member_id - LEFT JOIN groups g ON g.group_id = m.group_id - WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL - |] - (userId, probeHash) + DB.query + db + [sql| + SELECT r.contact_id, g.group_id, r.group_member_id + FROM received_probes r + LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON r.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL + |] + (userId, probeHash) currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds from + let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) - pure cgmIds $>>= getContactOrGroupMember_ db user + catMaybes <$> mapM (getContactOrMember_ db user) cgmIds -matchReceivedProbeHash :: DB.Connection -> User -> ContactOrGroupMember -> ProbeHash -> IO (Maybe (ContactOrGroupMember, Probe)) +matchReceivedProbeHash :: DB.Connection -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do probeIds <- maybeFirstRow id $ @@ -1255,18 +1276,18 @@ matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do |] (userId, probeHash) currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds from + let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probeHash, userId, currentTs, currentTs) - pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrGroupMember_ db user cgmIds + pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db user cgmIds -matchSentProbe :: DB.Connection -> User -> ContactOrGroupMember -> Probe -> IO (Maybe ContactOrGroupMember) -matchSentProbe db user@User {userId} _from (Probe probe) = - cgmIds $>>= getContactOrGroupMember_ db user +matchSentProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) +matchSentProbe db user@User {userId} _from (Probe probe) = + cgmIds $>>= getContactOrMember_ db user where - (ctId, gmId) = contactOrGroupMemberIds _from + (ctId, gmId) = contactOrMemberIds _from cgmIds = maybeFirstRow id $ DB.query @@ -1283,80 +1304,103 @@ matchSentProbe db user@User {userId} _from (Probe probe) = |] (userId, probe, ctId, gmId) -getContactOrGroupMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrGroupMember) -getContactOrGroupMember_ db user ids = +getContactOrMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) +getContactOrMember_ db user ids = fmap eitherToMaybe . runExceptT $ case ids of - (Just ctId, _, _) -> CGMContact <$> getContact db user ctId - (_, Just gId, Just gmId) -> CGMGroupMember <$> getGroupInfo db user gId <*> getGroupMember db user gId gmId + (Just ctId, _, _) -> COMContact <$> getContact db user ctId + (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db user gId gmId _ -> throwError $ SEInternalError "" -mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO () -mergeContactRecords db userId ct1 ct2 = do - let (toCt, fromCt) = toFromContacts ct1 ct2 - Contact {contactId = toContactId} = toCt - Contact {contactId = fromContactId, localDisplayName} = fromCt - currentTs <- getCurrentTime - -- TODO next query fixes incorrect unused contacts deletion; consider more thorough fix - when (contactDirect toCt && not (contactUsed toCt)) $ +-- connection being verified and connection level 0 have priority over requested merge direction; +-- if requested merge direction is overruled, keepLDN is kept +mergeContactRecords :: DB.Connection -> User -> Contact -> Contact -> ExceptT StoreError IO Contact +mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN} from = do + let (toCt, fromCt) = checkToFromContacts + Contact {contactId = toContactId, localDisplayName = toLDN} = toCt + Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt + liftIO $ do + currentTs <- getCurrentTime + -- next query fixes incorrect unused contacts deletion + when (contactDirect toCt && not (contactUsed toCt)) $ + DB.execute + db + "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" + (currentTs, userId, toContactId) DB.execute db - "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" - (currentTs, userId, toContactId) - DB.execute - db - "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.executeNamed - db - [sql| - UPDATE group_members - SET contact_id = :to_contact_id, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), - updated_at = :updated_at - WHERE contact_id = :from_contact_id - AND user_id = :user_id - |] - [ ":to_contact_id" := toContactId, - ":from_contact_id" := fromContactId, - ":user_id" := userId, - ":updated_at" := currentTs - ] - deleteContactProfile_ db userId fromContactId - DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) - deleteUnusedDisplayName_ db userId localDisplayName + "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE sent_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE sent_probe_hashes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE received_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.executeNamed + db + [sql| + UPDATE group_members + SET contact_id = :to_contact_id, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), + updated_at = :updated_at + WHERE contact_id = :from_contact_id + AND user_id = :user_id + |] + [ ":to_contact_id" := toContactId, + ":from_contact_id" := fromContactId, + ":user_id" := userId, + ":updated_at" := currentTs + ] + deleteContactProfile_ db userId fromContactId + DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) + deleteUnusedDisplayName_ db userId fromLDN + when (keepLDN /= toLDN && keepLDN == fromLDN) $ + DB.execute + db + [sql| + UPDATE display_names + SET local_display_name = ?, updated_at = ? + WHERE user_id = ? AND local_display_name = ? + |] + (keepLDN, currentTs, userId, toLDN) + getContact db user toContactId where - toFromContacts :: Contact -> Contact -> (Contact, Contact) - toFromContacts c1 c2 - | d1 && not d2 = (c1, c2) - | d2 && not d1 = (c2, c1) - | ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2) - | otherwise = (c2, c1) + checkToFromContacts :: (Contact, Contact) + checkToFromContacts + | vrfFrom && not vrfTo = (from, to) + | dirFrom && not vrfTo && not dirTo = (from, to) + | otherwise = (to, from) where - d1 = directOrUsed c1 - d2 = directOrUsed c2 - ctCreatedAt Contact {createdAt} = createdAt + vrfTo = isJust $ contactSecurityCode to + vrfFrom = isJust $ contactSecurityCode from + dirTo = let Contact {activeConn = Connection {connLevel = clTo}} = to in clTo == 0 + dirFrom = let Contact {activeConn = Connection {connLevel = clFrom}} = from in clFrom == 0 -updateMemberContact :: DB.Connection -> User -> Contact -> GroupMember -> IO () -updateMemberContact +associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () +associateMemberWithContactRecord db User {userId} Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} = do - -- TODO possibly, we should update profiles and local_display_names of all members linked to the same remote user, - -- once we decide on how we identify it, either based on shared contact_profile_id or on local_display_name currentTs <- getCurrentTime DB.execute db @@ -1369,6 +1413,34 @@ updateMemberContact when (memProfileId /= profileId) $ deleteUnusedProfile_ db userId memProfileId when (memLDN /= localDisplayName) $ deleteUnusedDisplayName_ db userId memLDN +associateContactWithMemberRecord :: DB.Connection -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact +associateContactWithMemberRecord + db + user@User {userId} + GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} + Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} = do + liftIO $ do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + |] + (contactId, currentTs, userId, groupId, groupMemberId) + DB.execute + db + [sql| + UPDATE contacts + SET local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + |] + (memLDN, memProfileId, currentTs, userId, contactId) + when (profileId /= memProfileId) $ deleteUnusedProfile_ db userId profileId + when (localDisplayName /= memLDN) $ deleteUnusedDisplayName_ db userId localDisplayName + getContact db user contactId + deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = DB.executeNamed @@ -1407,6 +1479,12 @@ deleteUnusedDisplayName_ db userId localDisplayName = |] [":user_id" := userId, ":local_display_name" := localDisplayName] +deleteOldProbes :: DB.Connection -> UTCTime -> IO () +deleteOldProbes db createdAtCutoff = do + DB.execute db "DELETE FROM sent_probes WHERE created_at <= ?" (Only createdAtCutoff) + DB.execute db "DELETE FROM sent_probe_hashes WHERE created_at <= ?" (Only createdAtCutoff) + DB.execute db "DELETE FROM received_probes WHERE created_at <= ?" (Only createdAtCutoff) + updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs, sendRcpts, favorite} = DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, sendRcpts, favorite, userId, groupId) @@ -1511,15 +1589,15 @@ createMemberContact db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, + user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, contactId, customUserProfileId) + ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, True, contactId, customUserProfileId) :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db - let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} @@ -1622,9 +1700,9 @@ createMemberContactConn_ peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, ConnNew, ConnContact, contactId, customUserProfileId) + ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db setCommandConnId db user cmdId connId - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 458944b6e5..b996f7626f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -481,7 +481,7 @@ getDirectChatPreviews_ db user@User {userId} = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, -- ChatStats diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 2f5bfec9ea..3ef68874b0 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -82,6 +82,7 @@ import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230926_contact_status +import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -163,7 +164,8 @@ schemaMigrations = ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), - ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), + ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e521cb43cf..a577796810 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -320,7 +320,7 @@ getUserAddressConnections db User {userId} = do db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -335,7 +335,7 @@ getUserContactLinks db User {userId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 4dc4f6e82d..3ff765b75d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -138,16 +138,16 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = let entityId = entityId_ connType connectionCode = SecurityCode <$> code_ <*> verifiedAt_ peerChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} + in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias, entityId, connectionCode, authErrCounter, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -157,8 +157,8 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection @@ -180,7 +180,7 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange :. (minV, maxV, subMode == SMOnlyCreate) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, contactConnInitiated = False, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} where ent ct = if connType == ct then entityId else Nothing diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 43265671b6..529d2bf019 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -244,18 +244,18 @@ data ContactRef = ContactRef instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions -data ContactOrGroupMember = CGMContact Contact | CGMGroupMember GroupInfo GroupMember +data ContactOrMember = COMContact Contact | COMGroupMember GroupMember deriving (Show) -contactOrGroupMemberIds :: ContactOrGroupMember -> (Maybe ContactId, Maybe GroupMemberId) -contactOrGroupMemberIds = \case - CGMContact Contact {contactId} -> (Just contactId, Nothing) - CGMGroupMember _ GroupMember {groupMemberId} -> (Nothing, Just groupMemberId) +contactOrMemberIds :: ContactOrMember -> (Maybe ContactId, Maybe GroupMemberId) +contactOrMemberIds = \case + COMContact Contact {contactId} -> (Just contactId, Nothing) + COMGroupMember GroupMember {groupMemberId} -> (Nothing, Just groupMemberId) -contactOrGroupMemberIncognito :: ContactOrGroupMember -> IncognitoEnabled -contactOrGroupMemberIncognito = \case - CGMContact ct -> contactConnIncognito ct - CGMGroupMember _ m -> memberIncognito m +contactOrMemberIncognito :: ContactOrMember -> IncognitoEnabled +contactOrMemberIncognito = \case + COMContact ct -> contactConnIncognito ct + COMGroupMember m -> memberIncognito m data UserContact = UserContact { userContactLinkId :: Int64, @@ -1235,6 +1235,7 @@ data Connection = Connection customUserProfileId :: Maybe Int64, connType :: ConnType, connStatus :: ConnStatus, + contactConnInitiated :: Bool, localAlias :: Text, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID connectionCode :: Maybe SecurityCode, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5eb9df3ad2..b981929efc 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -175,7 +175,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' - CRContactsMerged u intoCt mergedCt -> ttyUser u $ viewContactsMerged intoCt mergedCt + CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci @@ -236,7 +236,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] - CRMemberContactConnected u ct g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " is merged into " <> ttyContact' ct] + CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g @@ -891,12 +891,18 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " -viewContactsMerged :: Contact -> Contact -> [StyledString] -viewContactsMerged c1 c2 = +viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] +viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, - "use " <> ttyToContact' c1 <> highlight' "" <> " to send messages" + "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] +viewContactAndMemberAssociated :: Contact -> GroupInfo -> GroupMember -> Contact -> [StyledString] +viewContactAndMemberAssociated ct g m ct' = + [ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m, + "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" + ] + viewUserProfile :: Profile -> [StyledString] viewUserProfile Profile {displayName, fullName} = [ "user profile: " <> ttyFullName displayName fullName, diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 5c4d96cc94..a8f9fbf9c6 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -29,7 +29,8 @@ import Test.Hspec chatDirectTests :: SpecWith FilePath chatDirectTests = do describe "direct messages" $ do - describe "add contact and send/receive message" testAddContact + describe "add contact and send/receive messages" testAddContact + it "clear chat with contact" testContactClear it "deleting contact deletes profile" testDeleteContactDeletesProfile it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply @@ -40,6 +41,9 @@ chatDirectTests = do it "direct timed message" testDirectTimedMessage it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact it "should send multiline message" testMultilineMessage + describe "contact merge" $ do + it "merge duplicate contacts" testContactMerge + it "new contact should merge with multiple existing contacts" testMergeContactMultipleContacts describe "SMP servers" $ do it "get and set SMP servers" testGetSetSMPServers it "test SMP server connection" testTestSMPServerConnection @@ -140,35 +144,6 @@ testAddContact = versionTestMatrix2 runTestAddContact bob #> "@alice how are you?" alice <# "bob> how are you?" chatsManyMessages alice bob - -- test adding the same contact one more time - local name will be different - alice ##> "/c" - inv' <- getInvitation alice - bob ##> ("/c " <> inv') - bob <## "confirmation sent!" - concurrently_ - (bob <## "alice_1 (Alice): contact is connected") - (alice <## "bob_1 (Bob): contact is connected") - alice #> "@bob_1 hello" - bob <# "alice_1> hello" - bob #> "@alice_1 hi" - alice <# "bob_1> hi" - alice @@@ [("@bob_1", "hi"), ("@bob", "how are you?")] - bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] - -- test deleting contact - alice ##> "/d bob_1" - alice <## "bob_1: contact is deleted" - bob <## "alice_1 (Alice) deleted contact with you" - alice ##> "@bob_1 hey" - alice <## "no contact bob_1" - alice @@@ [("@bob", "how are you?")] - alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice_1", "contact deleted"), ("@alice", "how are you?")] - bob `hasContactProfiles` ["alice", "alice", "bob"] - -- test clearing chat - alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") - alice #$> ("/_get chat @2 count=100", chat, []) - bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") - bob #$> ("/_get chat @2 count=100", chat, []) chatsEmpty alice bob = do alice @@@ [("@bob", lastChatFeature)] alice #$> ("/_get chat @2 count=100", chat, chatFeatures) @@ -195,6 +170,101 @@ testAddContact = versionTestMatrix2 runTestAddContact alice #$> ("/_read chat @2", id, "ok") bob #$> ("/_read chat @2", id, "ok") +testContactMerge :: HasCallStack => FilePath -> IO () +testContactMerge = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "contact alice_1 is merged into alice", + "use @alice to send messages" + ] + ] + alice <##> bob + alice @@@ [("@bob", "hey")] + alice `hasContactProfiles` ["alice", "bob"] + bob @@@ [("@alice", "hey")] + bob `hasContactProfiles` ["bob", "alice"] + +testMergeContactMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeContactMultipleContacts = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/contact_merge off" + bob <## "ok" + + connectUsers alice bob + + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob_1 (Bob): contact is connected") + (bob <## "alice_1 (Alice): contact is connected") + + alice `hasContactProfiles` ["alice", "bob", "bob"] + bob `hasContactProfiles` ["bob", "alice", "alice"] + + threadDelay 500000 + + bob ##> "/contact_merge on" + bob <## "ok" + + alice ##> "/c" + inv'' <- getInvitation alice + bob ##> ("/c " <> inv'') + bob <## "confirmation sent!" + concurrentlyN_ + [ alice + <### [ "bob_2 (Bob): contact is connected", + StartsWith "contact bob_2 is merged into bob", + StartsWith "use @bob", + StartsWith "contact bob_1 is merged into bob", + StartsWith "use @bob" + ], + bob + <### [ "alice_2 (Alice): contact is connected", + StartsWith "contact alice_2 is merged into alice", + StartsWith "use @alice", + StartsWith "contact alice_1 is merged into alice", + StartsWith "use @alice" + ] + ] + alice <##> bob + + alice ##> "/contacts" + alice <## "bob (Bob)" + bob ##> "/contacts" + bob <## "alice (Alice)" + alice `hasContactProfiles` ["alice", "bob"] + bob `hasContactProfiles` ["bob", "alice"] + +testContactClear :: HasCallStack => FilePath -> IO () +testContactClear = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + threadDelay 500000 + alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") + alice #$> ("/_get chat @2 count=100", chat, []) + bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") + bob #$> ("/_get chat @2 count=100", chat, []) + testDeleteContactDeletesProfile :: HasCallStack => FilePath -> IO () testDeleteContactDeletesProfile = testChat2 aliceProfile bobProfile $ @@ -2104,7 +2174,7 @@ testMsgDecryptError tmp = withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob hello again" - bob <# "alice> skipped message ID 9..11" + bob <# "alice> skipped message ID 10..12" bob <# "alice> hello again" bob #> "@alice received!" alice <# "bob> received!" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4280810ca6..083d2f85be 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -73,7 +73,14 @@ chatGroupTests = do testNoDirect _1 _0 False testNoDirect _1 _1 False it "members have different local display names in different groups" testNoDirectDifferentLDNs - it "member should connect to contact when profile match" testConnectMemberToContact + describe "merge members and contacts" $ do + it "new member should merge with existing contact" testMergeMemberExistingContact + it "new member should merge with multiple existing contacts" testMergeMemberMultipleContacts + it "new contact should merge with existing member" testMergeContactExistingMember + it "new contact should merge with existing member with associated contact" testMergeContactExistingMemberWithContact + it "new contact should merge with multiple existing members" testMergeContactMultipleMembers + it "new contact should merge with both existing members and contacts" testMergeContactExistingMembersAndContacts + it "new member contact is merged with existing contact" testMergeMemberContact describe "create member contact" $ do it "create contact with group member with invitation message" testMemberContactMessage it "create contact with group member without invitation message" testMemberContactNoMessage @@ -2734,8 +2741,8 @@ testNoDirectDifferentLDNs = bob <# ("#" <> gName <> " " <> cathLDN <> "> hey") ] -testConnectMemberToContact :: HasCallStack => FilePath -> IO () -testConnectMemberToContact = +testMergeMemberExistingContact :: HasCallStack => FilePath -> IO () +testMergeMemberExistingContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -2750,13 +2757,15 @@ testConnectMemberToContact = [ do alice <## "#team: you joined the group" alice <## "#team: member cath_1 (Catherine) is connected" - alice <## "member #team cath_1 is merged into cath", + alice <## "contact and member are merged: cath, #team cath_1" + alice <## "use @cath to send messages", do bob <## "#team: alice joined the group", do cath <## "#team: bob added alice_1 (Alice) to the group (connecting...)" cath <## "#team: new member alice_1 is connected" - cath <## "member #team alice_1 is merged into alice" + cath <## "contact and member are merged: alice, #team alice_1" + cath <## "use @alice to send messages" ] alice <##> cath alice #> "#team hello" @@ -2779,6 +2788,334 @@ testConnectMemberToContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] +testMergeMemberMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeMemberMultipleContacts = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/contact_merge off" + alice <## "ok" + + connectUsers alice bob + connectUsers alice cath + + alice ##> "/c" + inv' <- getInvitation alice + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (alice <## "cath_1 (Catherine): contact is connected") + (cath <## "alice_1 (Alice): contact is connected") + + alice `hasContactProfiles` ["alice", "bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "alice"] + + alice ##> "/contact_merge on" + alice <## "ok" + + createGroup2 "team" bob cath + bob ##> "/a #team alice" + bob <## "invitation to join the group #team sent to alice" + alice <## "#team: bob invites you to join the group as member" + alice <## "use /j team to accept" + alice ##> "/j team" + concurrentlyN_ + [ alice + <### [ "#team: you joined the group", + "#team: member cath_2 (Catherine) is connected", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact cath_", + StartsWith "use @cath" + ], + bob <## "#team: alice joined the group", + cath + <### [ "#team: bob added alice_2 (Alice) to the group (connecting...)", + "#team: new member alice_2 is connected", + StartsWith "contact and member are merged: alice", + StartsWith "use @alice", + StartsWith "contact alice_", + StartsWith "use @alice" + ] + ] + alice <##> cath + alice #> "#team hello" + bob <# "#team alice> hello" + cath <# "#team alice> hello" + cath #> "#team hello too" + bob <# "#team cath> hello too" + alice <# "#team cath> hello too" + + alice ##> "/contacts" + alice + <### [ "bob (Bob)", + "cath (Catherine)" + ] + cath ##> "/contacts" + cath + <### [ "alice (Alice)", + "bob (Bob)" + ] + alice `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactExistingMember :: HasCallStack => FilePath -> IO () +testMergeContactExistingMember = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_1 (Catherine): contact is connected", + "contact and member are merged: cath_1, #team cath", + "use @cath to send messages" + ], + cath + <### [ "bob_1 (Bob): contact is connected", + "contact and member are merged: bob_1, #team bob", + "use @bob to send messages" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactExistingMemberWithContact :: HasCallStack => FilePath -> IO () +testMergeContactExistingMemberWithContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + -- create contact, delete only for bob so he would send probe hash to member + bob ##> "/_create member contact #1 3" + bob <## "contact for member #team cath is created" + + bob ##> "/_invite member contact @3 text hi" + bob + <### [ "sent invitation to connect directly to member #team cath", + WithTime "@cath hi" + ] + cath + <### [ "#team bob is creating direct contact bob with you", + WithTime "bob> hi" + ] + concurrently_ + (bob <## "cath (Catherine): contact is connected") + (cath <## "bob (Bob): contact is connected") + bob <##> cath + + bob ##> "/_delete @3 notify=off" + bob <## "cath: contact is deleted" + + bob ##> "/contacts" + bob <### ["alice (Alice)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + + -- contact connects, member is merged + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_1 (Catherine): contact is connected", + "contact and member are merged: cath_1, #team cath", + "use @cath to send messages" + ], + cath + <### [ "bob_1 (Bob): contact is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () +testMergeContactMultipleMembers = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + create2Groups3 "team" "club" alice bob cath + + bob `hasContactProfiles` ["alice", "bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob", "bob"] + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_2 (Catherine): contact is connected", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath" + ], + cath + <### [ "bob_2 (Bob): contact is connected", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactExistingMembersAndContacts :: HasCallStack => FilePath -> IO () +testMergeContactExistingMembersAndContacts = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + bob ##> "/contact_merge off" + bob <## "ok" + + create2Groups3 "team" "club" alice bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (bob <## "cath_2 (Catherine): contact is connected") + (cath <## "bob_2 (Bob): contact is connected") + + bob `hasContactProfiles` ["alice", "bob", "cath", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob", "bob", "bob"] + + bob ##> "/contact_merge on" + bob <## "ok" + + bob ##> "/c" + inv'' <- getInvitation bob + cath ##> ("/c " <> inv'') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_3 (Catherine): contact is connected", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact cath_3 is merged into cath", + StartsWith "use @cath" + ], + cath + <### [ "bob_3 (Bob): contact is connected", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob", + StartsWith "contact bob_3 is merged into bob", + StartsWith "use @bob" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeMemberContact :: HasCallStack => FilePath -> IO () +testMergeMemberContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + bob ##> "/contact_merge off" + bob <## "ok" + + createGroup3 "team" alice bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (bob <## "cath_1 (Catherine): contact is connected") + (cath <## "bob_1 (Bob): contact is connected") + + bob `hasContactProfiles` ["alice", "bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob", "bob"] + + bob ##> "/contact_merge on" + bob <## "ok" + + -- bob and cath connect + bob ##> "/_create member contact #1 3" + bob <## "contact for member #team cath is created" + + bob ##> "/_invite member contact @4 text hi" + bob + <### [ "sent invitation to connect directly to member #team cath", + WithTime "@cath hi" + ] + cath + <### [ "#team bob is creating direct contact bob with you", + WithTime "bob> hi" + ] + concurrentlyN_ + [ bob + <### [ "cath (Catherine): contact is connected", + "contact cath_1 is merged into cath", + -- StartsWith "use @cath" + "use @cath to send messages" + ], + cath + <### [ "bob (Bob): contact is connected", + "contact bob_1 is merged into bob", + -- StartsWith "use @bob" + "use @bob to send messages" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + + -- group messages work + alice #> "#team hello" + concurrently_ + (bob <# "#team alice> hello") + (cath <# "#team alice> hello") + bob #> "#team hi there" + concurrently_ + (alice <# "#team bob> hi there") + (cath <# "#team bob> hi there") + cath #> "#team hey team" + concurrently_ + (alice <# "#team cath> hey team") + (bob <# "#team cath> hey team") + testMemberContactMessage :: HasCallStack => FilePath -> IO () testMemberContactMessage = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index da6cbd156f..d7cf682560 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -20,6 +20,7 @@ chatProfileTests = do it "use multiword profile names" testMultiWordProfileNames describe "user contact link" $ do it "create and connect via contact link" testUserContactLink + it "merge existing contact when connecting via contact link" testUserContactLinkMerge it "add contact link to profile" testProfileLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests @@ -218,6 +219,39 @@ testUserContactLink = alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath +testUserContactLinkMerge :: HasCallStack => FilePath -> IO () +testUserContactLinkMerge = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob) wants to connect to you!" + alice <## "to accept: /ac bob_1" + alice <## "to reject: /rc bob_1 (the sender will NOT be notified)" + alice @@@ [("@bob", "hey"), ("<@bob_1", "")] + alice ##> "/ac bob_1" + alice <## "bob_1 (Bob): accepting contact request..." + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "contact alice_1 is merged into alice", + "use @alice to send messages" + ] + ] + threadDelay 100000 + alice @@@ [("@bob", lastChatFeature)] + alice <##> bob + testProfileLink :: HasCallStack => FilePath -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 6831cf3190..d98431e815 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -277,7 +277,7 @@ cc <##.. ls = do unless prefix $ print ("expected to start from one of: " <> show ls, ", got: " <> l) prefix `shouldBe` True -data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String +data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String | StartsWith String deriving (Show) instance IsString ConsoleResponse where fromString = ConsoleString @@ -287,7 +287,7 @@ getInAnyOrder :: HasCallStack => (String -> String) -> TestCC -> [ConsoleRespons getInAnyOrder _ _ [] = pure () getInAnyOrder f cc ls = do line <- f <$> getTermLine cc - let rest = filter (not . expected line) ls + let rest = filterFirst (expected line) ls if length rest < length ls then getInAnyOrder f cc rest else error $ "unexpected output: " <> line @@ -297,6 +297,12 @@ getInAnyOrder f cc ls = do ConsoleString s -> l == s WithTime s -> dropTime_ l == Just s EndsWith s -> s `isSuffixOf` l + StartsWith s -> s `isPrefixOf` l + filterFirst :: (a -> Bool) -> [a] -> [a] + filterFirst _ [] = [] + filterFirst p (x:xs) + | p x = xs + | otherwise = x : filterFirst p xs (<###) :: HasCallStack => TestCC -> [ConsoleResponse] -> Expectation (<###) = getInAnyOrder id @@ -456,8 +462,11 @@ showName (TestCC ChatController {currentUser} _ _ _ _ _) = do pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName createGroup2 :: HasCallStack => String -> TestCC -> TestCC -> IO () -createGroup2 gName cc1 cc2 = do - connectUsers cc1 cc2 +createGroup2 gName cc1 cc2 = createGroup2' gName cc1 cc2 True + +createGroup2' :: HasCallStack => String -> TestCC -> TestCC -> Bool -> IO () +createGroup2' gName cc1 cc2 doConnectUsers = do + when doConnectUsers $ connectUsers cc1 cc2 name2 <- userName cc2 cc1 ##> ("/g " <> gName) cc1 <## ("group #" <> gName <> " is created") @@ -488,6 +497,24 @@ createGroup3 gName cc1 cc2 cc3 = do cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] +create2Groups3 :: HasCallStack => String -> String -> TestCC -> TestCC -> TestCC -> IO () +create2Groups3 gName1 gName2 cc1 cc2 cc3 = do + createGroup3 gName1 cc1 cc2 cc3 + createGroup2' gName2 cc1 cc2 False + name1 <- userName cc1 + name3 <- userName cc3 + addMember gName2 cc1 cc3 GRAdmin + cc3 ##> ("/j " <> gName2) + concurrentlyN_ + [ cc1 <## ("#" <> gName2 <> ": " <> name3 <> " joined the group"), + do + cc3 <## ("#" <> gName2 <> ": you joined the group") + cc3 <##. ("#" <> gName2 <> ": member "), -- "#gName2: member sName2 is connected" + do + cc2 <##. ("#" <> gName2 <> ": " <> name1 <> " added ") -- "#gName2: name1 added sName3 to the group (connecting...)" + cc2 <##. ("#" <> gName2 <> ": new member ") -- "#gName2: new member name3 is connected" + ] + addMember :: HasCallStack => String -> TestCC -> TestCC -> GroupMemberRole -> IO () addMember gName = fullAddMember gName "" From 1be70169bab98d135e9618cac4771663ef20b53a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:27:13 +0400 Subject: [PATCH 02/80] docs: contact merge issues rfc (#3179) --- .../2023-10-05-contact-merge-improvement.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/rfcs/2023-10-05-contact-merge-improvement.md diff --git a/docs/rfcs/2023-10-05-contact-merge-improvement.md b/docs/rfcs/2023-10-05-contact-merge-improvement.md new file mode 100644 index 0000000000..e0588998ec --- /dev/null +++ b/docs/rfcs/2023-10-05-contact-merge-improvement.md @@ -0,0 +1,92 @@ +# Contact merge issues, eradication, improvement + +## Problem + +Contact merge (see mergeContactRecords) keeps connections of both contacts by assigning both connections to the merged contact. + +When contact is read, last ready connection is selected as contact's active connection (see, for example, getContact - connections are ordered by connection_id in descending order). This may lead to contacts reading different connections, and as a result message delivery failing. Consider following scenarios: +- connection IDs may be in different order for each side, if one of sides inserted connection in gap of IDs after an older (different) connection was deleted. If contacts consider different connections as active for each other, they would subscribe to and send to different connections, completely destabilizing delivery. +- XInfoProbeOk may never be delivered due to server error or processed due to client error. In this case probe sender has 2 separate contacts and user may still use old contact for sending; other side now has a single merged contact record with the latest connection as active - if they restart or resubscribe they would stop receiving messages from the old connection. Perhaps it's less of an issue because that second side would at least send to a new connection which first side (probe sender) considers active for a separate contact, and would receive messages to that new contact. Also if they're establishing a second contact-pair, they may be more likely to use this new contact-pair anyway. + +## Solution ideas + +### Don't merge contacts + +Still merge members and contacts. + +Pros: +- no risk of destabilizing existing conversation. +- eventually (paired with a migration for legacy merged contacts) contact reading queries may be simplified to read a single connection, as well several chat logic flows accounting for multiple contact connections. +- direct connection not being replaced with a new connection established via group (in case of "send direct message" feature, or pre 5.3 in case of direct connections established in group). +- connection verification status not being reset (same + duplicate direct connections). +- feature of having separate parallel conversations with same contact is kept. +- easy to implement: + - in probeMatchingContactsAndMembers only match members (getMatchingMembers), don't match contacts (remove getMatchingContacts). + - in probeMatch prohibit merging contacts (cgm1 is COMContact, cgm2 is COMContact case; also member with associated contact case). + - in xInfoProbeOk - same. + +Cons: +- "split identity" of contacts - though in many cases it's status quo without latest change (https://github.com/simplex-chat/simplex-chat/pull/3173). It may be not a big enough issue to justify risking connection stability. +- members may be matched to arbitrary contacts - need to consider consequences more thoroughly. Though at least they should match to same contact-pair on both sides. "Send direct message" member contacts will not be matched to existing contacts, though going forward with contact-member merge working it shouldn't be an issue until contact is deleted on one of sides. +- group link host not being merged - member knowing host as a contact would have him as a separate contact now. Perhaps we could rework group link protocol to avoid contact creation - new connection would already be created for host-invitee group connection, with some new connection entity "pending group member" created on both sides (starting with next protocol version support on both sides). This member would then be merged to the existing contact. This would be the most expensive part of this change, if we choose not to ignore it. + +### Improve contact merge protocol + +- instead of relying on ordering when selecting active contact connection, add flag active_contact_conn, set it on mergeContactRecords: + + ~~verified connection >~~ direct connection > newer connection + + we can't set to verified connection because it's not necessarily symmetric + + ~~direct connection >~~ newer connection + + for backwards compatibility we can't set to direct - other side may still set to newer. Unless we bump protocol version and choose based on that. + + choose "newer" connection based on created_at instead of connection_id - it should be more likely to have the same order. + +- initial probe sender to send new message XInfoProbeComplete after processing XInfoProbeOk to signal that old connection is good to delete. + +- probe receiver to keep subscribing to both connections until they receive XInfoProbeComplete. + +- negotiate connection to use - shared connection id? + +``` + Alice Bob + 2 contacts | | 2 contacts + 2 connections | | 2 connections + | XInfoProbe | + |------------------------>| + | XInfoProbeCheck | + |------------------------>| + | | match probe and hash; + | | merge contacts + | | 1 contact + | | 2 connections + | | (1 active, subscribe to both) + | XInfoProbeOk | + |<------------------------| + merge contacts | | + 1 contact | | + 1 connection | | + (delete non active) | | + | XInfoProbeComplete | + |------------------------>| + | | 1 contact + | | 1 connection + | | (delete non active) + * * +``` + +Pros: +- contact merge is already implemented. +- maintains contact "identity". +- members are matched to a single contact. +- no need to rework group links. + +Cons: +- more complex logic - more prone to error. +- likely still risks destabilizing connection. +- continue to maintain and add new chat logic flows accounting for multiple connections (for instance, subscription). +- newer connection replace verified and/or direct connections. These new connections are still possible to establish via indirect and unverified group connection ("send direct message"). +- removes "parallel conversations" feature. It's not a hard loss though. +- existing contacts with aliases still not to be merged - to be implemented. Not a con, but a special case for contact merge. From 76fb5b6dca2d3d6e920a5647f25a2f0b6c31e192 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 8 Oct 2023 02:00:40 +0800 Subject: [PATCH 03/80] android: fix lock not showing (#3181) * android: fix lock not showing * better fix --- .../android/src/main/java/chat/simplex/app/MainActivity.kt | 2 +- .../kotlin/chat/simplex/common/platform/UI.android.kt | 2 ++ .../common/src/commonMain/kotlin/chat/simplex/common/App.kt | 2 +- .../common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt | 1 - .../src/commonMain/kotlin/chat/simplex/common/platform/UI.kt | 2 ++ .../kotlin/chat/simplex/common/platform/UI.desktop.kt | 2 ++ 6 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 512e9efc10..2dff3d604b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -91,11 +91,11 @@ class MainActivity: FragmentActivity() { // When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch AppLock.clearAuthState() AppLock.laFailed.value = true - AppLock.destroyedAfterBackPress.value = true } if (!onBackPressedDispatcher.hasEnabledCallbacks()) { // Drop shared content SimplexApp.context.chatModel.sharedContent.value = null + finish() } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index c458561f96..ca497cbc5b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -69,3 +69,5 @@ actual fun hideKeyboard(view: Any?) { (androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0) } } + +actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index c08ad5f914..98b7b279a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -149,7 +149,7 @@ fun MainScreen() { LaunchedEffect(Unit) { // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request // while the screen moves to a launcher. Detect it and prevent showing the auth - if (!(AppLock.destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { + if (!(androidIsFinishingMainActivity() && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { AppLock.runAuthenticate() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index 7228f4ebf4..a1d4c0c62a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -24,7 +24,6 @@ object AppLock { // Remember result and show it after orientation change val laFailed = mutableStateOf(false) - val destroyedAfterBackPress = mutableStateOf(false) fun clearAuthState() { userAuthorized.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt index 38629f038b..c61d8564c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt @@ -14,3 +14,5 @@ expect fun LocalMultiplatformView(): Any? @Composable expect fun getKeyboardState(): State expect fun hideKeyboard(view: Any?) + +expect fun androidIsFinishingMainActivity(): Boolean diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt index 1ea5018fcc..94d42ba792 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt @@ -17,3 +17,5 @@ actual fun LocalMultiplatformView(): Any? = null @Composable actual fun getKeyboardState(): State = remember { mutableStateOf(KeyboardState.Opened) } actual fun hideKeyboard(view: Any?) {} + +actual fun androidIsFinishingMainActivity(): Boolean = false From d50c7ad7f63322f7651848b0a2d49b0c1f42ac35 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Sun, 8 Oct 2023 15:37:28 +0800 Subject: [PATCH 04/80] bump hackage (#3185) --- flake.lock | 6 +++--- flake.nix | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index 2c133a9b63..81b3f6f6b8 100644 --- a/flake.lock +++ b/flake.lock @@ -288,11 +288,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1676679913, - "narHash": "sha256-nW7ApRgiA9uChV/UrW89HK75rIToLt7XtSrkodO0Nbc=", + "lastModified": 1696724662, + "narHash": "sha256-jV2ugSjZE0FjMYR2YIx0p2cDBqd+xxhZrRxp5BmieYk=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "c37cffd51315d8e27dd8d3faf75abf897e39c8c8", + "rev": "df603bff8606d8653a0876ae0c3fd1f9014882f2", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f7e86fbb6f..f6a68246e2 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { compiler-nix-name = "ghc8107"; - index-state = "2022-06-20T00:00:00Z"; + index-state = "2023-10-06T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. projectFileName = "cabal.project"; From 985b9837c38b465b21e580b5c91e4f109e7de962 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 8 Oct 2023 16:22:53 +0100 Subject: [PATCH 05/80] core: api to close database connection (#3186) --- src/Simplex/Chat/Mobile.hs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 700548bb12..0db9d7265e 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -3,11 +3,12 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Mobile where import Control.Concurrent.STM -import Control.Exception (catch) +import Control.Exception (catch, SomeException) import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (ToJSON (..)) @@ -40,8 +41,9 @@ import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types +import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -53,6 +55,8 @@ import System.Timeout (timeout) foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString +foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString + foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString @@ -95,6 +99,9 @@ cChatMigrateInit fp key conf ctrl = do Left e -> pure e newCStringFromLazyBS $ J.encode r +cChatCloseStore :: StablePtr ChatController -> IO CString +cChatCloseStore cPtr = deRefStablePtr cPtr >>= chatCloseStore >>= newCAString + -- | send command to chat (same syntax as in terminal for now) cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString cChatSendCmd cPtr cCmd = do @@ -195,6 +202,14 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do _ -> dbError e dbError e = Left . DBMErrorSQL dbFile $ show e +chatCloseStore :: ChatController -> IO String +chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do + closeSQLiteStore chatStore + closeSQLiteStore $ agentClientStore smpAgent + +handleErr :: IO () -> IO String +handleErr a = (a $> "") `catch` (pure . show @SomeException) + chatSendCmd :: ChatController -> ByteString -> IO JSONByteString chatSendCmd cc s = J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc From ab46cbc5dd5c77838002a7ebdb2263e39e43658f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 9 Oct 2023 09:46:58 +0400 Subject: [PATCH 06/80] core: relax contact probing: don't send probe hashes to new contacts except group link hosts; still send probe hashes to group members (#3180) --- src/Simplex/Chat.hs | 95 +++++----- src/Simplex/Chat/Store/Direct.hs | 9 +- src/Simplex/Chat/Store/Groups.hs | 136 ++++++--------- src/Simplex/Chat/Store/Messages.hs | 3 +- tests/ChatTests/Direct.hs | 101 +++++------ tests/ChatTests/Groups.hs | 271 +++-------------------------- tests/ChatTests/Profiles.hs | 34 ---- 7 files changed, 176 insertions(+), 473 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 32d59c9cc5..296abf0e2b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,7 +39,6 @@ import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, foldl', isSuffixOf, partition, sortOn) -import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -3106,7 +3105,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveC c showToast (c <> "> ") "connected" when (contactConnInitiated conn) $ do - probeMatchingContactsAndMembers ct (contactConnIncognito ct) + let Connection {groupLinkId} = conn + doProbeContacts = isJust groupLinkId + probeMatchingContactsAndMembers ct (contactConnIncognito ct) doProbeContacts withStore' $ \db -> resetContactConnInitiated db user conn forM_ viaUserContactLink $ \userContactLinkId -> withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case @@ -3125,7 +3126,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True SENT msgId -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId @@ -3143,8 +3144,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> setConnectionVerified db user connId Nothing let ct' = ct {activeConn = conn {connectionCode = Nothing}} :: Contact ratchetSyncEventItem ct' - toView $ CRContactVerificationReset user ct' - createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent RCEVerificationCodeReset) Nothing + securityCodeChanged ct' _ -> ratchetSyncEventItem ct where processErr cryptoErr = do @@ -3298,7 +3298,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do @@ -3691,8 +3691,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> m () - probeMatchingContactsAndMembers ct connectedIncognito = do + probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () + probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do gVar <- asks idsDrg contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito @@ -3703,7 +3703,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- sendProbe -> sendProbeHashes (currently) -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + cs <- if doProbeContacts + then map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + else pure [] ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) sendProbeHashes (cs <> ms) probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) @@ -4363,32 +4365,18 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do COMContact <$$> mergeContacts c1 c2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} - | profilesMatch p1 p2 -> case memberContactId of - Nothing -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - COMContact <$$> associateMemberAndContact c1 m2 - Just mCtId - | mCtId /= cId1 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - mCt <- withStore $ \db -> getContact db user mCtId - COMContact <$$> mergeContacts c1 mCt - | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing - | otherwise -> messageWarning "probeMatch ignored: profiles don't match" >> pure Nothing + | isNothing memberContactId && profilesMatch p1 p2 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> associateMemberAndContact c1 m2 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" >> pure Nothing COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId, activeConn = Just conn} -> case cgm2 of - COMContact c2@Contact {contactId = cId2, profile = p2} - | memberCurrent m1 && profilesMatch p1 p2 -> case memberContactId of - Nothing -> do - void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) - COMContact <$$> associateMemberAndContact c2 m1 - Just mCtId - | mCtId /= cId2 -> do - void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) - mCt <- withStore $ \db -> getContact db user mCtId - COMContact <$$> mergeContacts c2 mCt - | otherwise -> messageWarning "probeMatch ignored: same contact id" >> pure Nothing - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member not current" >> pure Nothing + COMContact c2@Contact {profile = p2} + | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do + void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + COMContact <$$> associateMemberAndContact c2 m1 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing xInfoProbeOk :: ContactOrMember -> Probe -> m () @@ -4400,24 +4388,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just (COMContact c2@Contact {contactId = cId2}) | cId1 /= cId2 -> void $ mergeContacts c1 c2 | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" - Just (COMGroupMember m2@GroupMember {memberContactId}) -> - case memberContactId of - Nothing -> void $ associateMemberAndContact c1 m2 - Just mCtId - | mCtId /= cId1 -> do - mCt <- withStore $ \db -> getContact db user mCtId - void $ mergeContacts c1 mCt - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMGroupMember m2@GroupMember {memberContactId}) + | isNothing memberContactId -> void $ associateMemberAndContact c1 m2 + | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" _ -> pure () COMGroupMember m1@GroupMember {memberContactId} -> case cgm2 of - Just (COMContact c2@Contact {contactId = cId2}) -> case memberContactId of - Nothing -> void $ associateMemberAndContact c2 m1 - Just mCtId - | mCtId /= cId2 -> do - mCt <- withStore $ \db -> getContact db user mCtId - void $ mergeContacts c2 mCt - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMContact c2) + | isNothing memberContactId -> void $ associateMemberAndContact c2 m1 + | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" Just (COMGroupMember _) -> messageWarning "xInfoProbeOk ignored: members are not matched with members" _ -> pure () @@ -4540,7 +4519,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do merge c1' c2' = do c2'' <- withStore $ \db -> mergeContactRecords db user c1' c2' toView $ CRContactsMerged user c1' c2' c2'' + when (directOrUsed c2'') $ showSecurityCodeChanged c2'' pure $ Just c2'' + where + showSecurityCodeChanged mergedCt = do + let sc1_ = contactSecurityCode c1' + sc2_ = contactSecurityCode c2' + scMerged_ = contactSecurityCode mergedCt + case (sc1_, sc2_) of + (Just sc1, Nothing) + | scMerged_ /= Just sc1 -> securityCodeChanged mergedCt + | otherwise -> pure () + (Nothing, Just sc2) + | scMerged_ /= Just sc2 -> securityCodeChanged mergedCt + | otherwise -> pure () + _ -> pure () associateMemberAndContact :: Contact -> GroupMember -> m (Maybe Contact) associateMemberAndContact c m = do @@ -4796,9 +4789,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ mContent_ $ \mc -> do ci <- saveRcvChatItem user (CDDirectRcv mCt') msg msgMeta (CIRcvMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat mCt') ci) - securityCodeChanged ct = do - toView $ CRContactVerificationReset user ct - createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing + + securityCodeChanged :: Contact -> m () + securityCodeChanged ct = do + toView $ CRContactVerificationReset user ct + createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 20b81def8f..91243e2319 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -159,7 +159,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.deleted = 0 - ORDER BY c.connection_id DESC + ORDER BY c.created_at DESC LIMIT 1 |] (userId, cReqHash) @@ -517,7 +517,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.connection_id DESC + ORDER BY c.created_at DESC LIMIT 1 |] (userId, xContactId) @@ -667,7 +667,7 @@ getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO C getContact_ db user@User {userId} contactId deleted = ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $ DB.query - db + db [sql| SELECT -- Contact @@ -686,10 +686,11 @@ getContact_ db user@User {userId} contactId deleted = SELECT cc_connection_id FROM ( SELECT cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord FROM connections cc WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 ) ) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index f6a4233c1f..236031da94 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -107,7 +107,7 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Either (rights) import Data.Int (Int64) -import Data.List (sortOn) +import Data.List (partition, sortOn) import Data.Maybe (fromMaybe, isNothing, catMaybes, isJust) import Data.Ord (Down (..)) import Data.Text (Text) @@ -695,31 +695,21 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co ) getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact -getContactViaMember db user@User {userId} GroupMember {groupMemberId} = - ExceptT $ - firstRow (toContact user) (SEContactNotFoundByMemberId groupMemberId) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN group_members m ON m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - |] - (userId, groupMemberId) +getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do + contactId <- + ExceptT $ + firstRow fromOnly (SEContactNotFoundByMemberId groupMemberId) $ + DB.query + db + [sql| + SELECT ct.contact_id + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + |] + (userId, groupMemberId) + getContact db user contactId setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -1041,37 +1031,21 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) -getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = - maybeFirstRow toContact' $ - DB.query - db - [sql| - SELECT - ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = ct.via_group - JOIN group_members m ON m.group_id = g.group_id AND m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - |] - (userId, groupMemberId) - where - toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact - toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} - activeConn = toConnection connRow - mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} +getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do + contactId_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT ct.contact_id + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + |] + (userId, groupMemberId) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} @@ -1258,7 +1232,16 @@ matchReceivedProbe db user@User {userId} from (Probe probe) = do db "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) - catMaybes <$> mapM (getContactOrMember_ db user) cgmIds + let cgmIds' = filterFirstContactId cgmIds + catMaybes <$> mapM (getContactOrMember_ db user) cgmIds' + where + filterFirstContactId :: [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] -> [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] + filterFirstContactId cgmIds = do + let (ctIds, memIds) = partition (\(ctId, _, _) -> isJust ctId) cgmIds + ctIds' = case ctIds of + [] -> [] + (x : _) -> [x] + ctIds' <> memIds matchReceivedProbeHash :: DB.Connection -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do @@ -1284,7 +1267,7 @@ matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db user cgmIds matchSentProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) -matchSentProbe db user@User {userId} _from (Probe probe) = +matchSentProbe db user@User {userId} _from (Probe probe) = do cgmIds $>>= getContactOrMember_ db user where (ctId, gmId) = contactOrMemberIds _from @@ -1311,11 +1294,10 @@ getContactOrMember_ db user ids = (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db user gId gmId _ -> throwError $ SEInternalError "" --- connection being verified and connection level 0 have priority over requested merge direction; --- if requested merge direction is overruled, keepLDN is kept +-- if requested merge direction is overruled (toFromContacts), keepLDN is kept mergeContactRecords :: DB.Connection -> User -> Contact -> Contact -> ExceptT StoreError IO Contact mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN} from = do - let (toCt, fromCt) = checkToFromContacts + let (toCt, fromCt) = toFromContacts to from Contact {contactId = toContactId, localDisplayName = toLDN} = toCt Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt liftIO $ do @@ -1342,18 +1324,6 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN db "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE sent_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE sent_probe_hashes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE received_probes SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) DB.executeNamed db [sql| @@ -1384,16 +1354,16 @@ mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN (keepLDN, currentTs, userId, toLDN) getContact db user toContactId where - checkToFromContacts :: (Contact, Contact) - checkToFromContacts - | vrfFrom && not vrfTo = (from, to) - | dirFrom && not vrfTo && not dirTo = (from, to) - | otherwise = (to, from) + toFromContacts :: Contact -> Contact -> (Contact, Contact) + toFromContacts c1 c2 + | d1 && not d2 = (c1, c2) + | d2 && not d1 = (c2, c1) + | ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2) + | otherwise = (c2, c1) where - vrfTo = isJust $ contactSecurityCode to - vrfFrom = isJust $ contactSecurityCode from - dirTo = let Contact {activeConn = Connection {connLevel = clTo}} = to in clTo == 0 - dirFrom = let Contact {activeConn = Connection {connLevel = clFrom}} = from in clFrom == 0 + d1 = directOrUsed c1 + d2 = directOrUsed c2 + ctCreatedAt Contact {createdAt} = createdAt associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () associateMemberWithContactRecord diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b996f7626f..9ad0e8edc8 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -517,10 +517,11 @@ getDirectChatPreviews_ db user@User {userId} = do SELECT cc_connection_id FROM ( SELECT cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord FROM connections cc WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 ) ) diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index a8f9fbf9c6..a5fc7455c8 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -41,9 +41,9 @@ chatDirectTests = do it "direct timed message" testDirectTimedMessage it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact it "should send multiline message" testMultilineMessage - describe "contact merge" $ do - it "merge duplicate contacts" testContactMerge - it "new contact should merge with multiple existing contacts" testMergeContactMultipleContacts + describe "duplicate contacts" $ do + it "duplicate contacts are separate (contacts don't merge)" testDuplicateContactsSeparate + it "new contact is separate with multiple duplicate contacts (contacts don't merge)" testDuplicateContactsMultipleSeparate describe "SMP servers" $ do it "get and set SMP servers" testGetSetSMPServers it "test SMP server connection" testTestSMPServerConnection @@ -170,44 +170,13 @@ testAddContact = versionTestMatrix2 runTestAddContact alice #$> ("/_read chat @2", id, "ok") bob #$> ("/_read chat @2", id, "ok") -testContactMerge :: HasCallStack => FilePath -> IO () -testContactMerge = +testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsSeparate = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice <##> bob - alice ##> "/c" - inv' <- getInvitation alice - bob ##> ("/c " <> inv') - bob <## "confirmation sent!" - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages" - ] - ] - alice <##> bob - alice @@@ [("@bob", "hey")] - alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice", "hey")] - bob `hasContactProfiles` ["bob", "alice"] - -testMergeContactMultipleContacts :: HasCallStack => FilePath -> IO () -testMergeContactMultipleContacts = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - bob ##> "/contact_merge off" - bob <## "ok" - - connectUsers alice bob - alice ##> "/c" inv' <- getInvitation alice bob ##> ("/c " <> inv') @@ -216,42 +185,56 @@ testMergeContactMultipleContacts = (alice <## "bob_1 (Bob): contact is connected") (bob <## "alice_1 (Alice): contact is connected") + alice <##> bob + alice #> "@bob_1 1" + bob <# "alice_1> 1" + bob #> "@alice_1 2" + alice <# "bob_1> 2" + + alice @@@ [("@bob", "hey"), ("@bob_1", "2")] alice `hasContactProfiles` ["alice", "bob", "bob"] + bob @@@ [("@alice", "hey"), ("@alice_1", "2")] bob `hasContactProfiles` ["bob", "alice", "alice"] - threadDelay 500000 +testDuplicateContactsMultipleSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsMultipleSeparate = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob - bob ##> "/contact_merge on" - bob <## "ok" + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob_1 (Bob): contact is connected") + (bob <## "alice_1 (Alice): contact is connected") alice ##> "/c" inv'' <- getInvitation alice bob ##> ("/c " <> inv'') bob <## "confirmation sent!" - concurrentlyN_ - [ alice - <### [ "bob_2 (Bob): contact is connected", - StartsWith "contact bob_2 is merged into bob", - StartsWith "use @bob", - StartsWith "contact bob_1 is merged into bob", - StartsWith "use @bob" - ], - bob - <### [ "alice_2 (Alice): contact is connected", - StartsWith "contact alice_2 is merged into alice", - StartsWith "use @alice", - StartsWith "contact alice_1 is merged into alice", - StartsWith "use @alice" - ] - ] + concurrently_ + (alice <## "bob_2 (Bob): contact is connected") + (bob <## "alice_2 (Alice): contact is connected") + alice <##> bob + alice #> "@bob_1 1" + bob <# "alice_1> 1" + bob #> "@alice_1 2" + alice <# "bob_1> 2" + alice #> "@bob_2 3" + bob <# "alice_2> 3" + bob #> "@alice_2 4" + alice <# "bob_2> 4" alice ##> "/contacts" - alice <## "bob (Bob)" + alice <### ["bob (Bob)", "bob_1 (Bob)", "bob_2 (Bob)"] bob ##> "/contacts" - bob <## "alice (Alice)" - alice `hasContactProfiles` ["alice", "bob"] - bob `hasContactProfiles` ["bob", "alice"] + bob <### ["alice (Alice)", "alice_1 (Alice)", "alice_2 (Alice)"] + alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] + bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] testContactClear :: HasCallStack => FilePath -> IO () testContactClear = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 083d2f85be..9fb6ac7f9b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -75,12 +75,9 @@ chatGroupTests = do it "members have different local display names in different groups" testNoDirectDifferentLDNs describe "merge members and contacts" $ do it "new member should merge with existing contact" testMergeMemberExistingContact - it "new member should merge with multiple existing contacts" testMergeMemberMultipleContacts it "new contact should merge with existing member" testMergeContactExistingMember - it "new contact should merge with existing member with associated contact" testMergeContactExistingMemberWithContact it "new contact should merge with multiple existing members" testMergeContactMultipleMembers - it "new contact should merge with both existing members and contacts" testMergeContactExistingMembersAndContacts - it "new member contact is merged with existing contact" testMergeMemberContact + it "new group link host contact should merge with single existing contact out of multiple" testMergeGroupLinkHostMultipleContacts describe "create member contact" $ do it "create contact with group member with invitation message" testMemberContactMessage it "create contact with group member without invitation message" testMemberContactNoMessage @@ -2788,76 +2785,6 @@ testMergeMemberExistingContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeMemberMultipleContacts :: HasCallStack => FilePath -> IO () -testMergeMemberMultipleContacts = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - alice ##> "/contact_merge off" - alice <## "ok" - - connectUsers alice bob - connectUsers alice cath - - alice ##> "/c" - inv' <- getInvitation alice - cath ##> ("/c " <> inv') - cath <## "confirmation sent!" - concurrently_ - (alice <## "cath_1 (Catherine): contact is connected") - (cath <## "alice_1 (Alice): contact is connected") - - alice `hasContactProfiles` ["alice", "bob", "cath", "cath"] - cath `hasContactProfiles` ["cath", "alice", "alice"] - - alice ##> "/contact_merge on" - alice <## "ok" - - createGroup2 "team" bob cath - bob ##> "/a #team alice" - bob <## "invitation to join the group #team sent to alice" - alice <## "#team: bob invites you to join the group as member" - alice <## "use /j team to accept" - alice ##> "/j team" - concurrentlyN_ - [ alice - <### [ "#team: you joined the group", - "#team: member cath_2 (Catherine) is connected", - StartsWith "contact and member are merged: cath", - StartsWith "use @cath", - StartsWith "contact cath_", - StartsWith "use @cath" - ], - bob <## "#team: alice joined the group", - cath - <### [ "#team: bob added alice_2 (Alice) to the group (connecting...)", - "#team: new member alice_2 is connected", - StartsWith "contact and member are merged: alice", - StartsWith "use @alice", - StartsWith "contact alice_", - StartsWith "use @alice" - ] - ] - alice <##> cath - alice #> "#team hello" - bob <# "#team alice> hello" - cath <# "#team alice> hello" - cath #> "#team hello too" - bob <# "#team cath> hello too" - alice <# "#team cath> hello too" - - alice ##> "/contacts" - alice - <### [ "bob (Bob)", - "cath (Catherine)" - ] - cath ##> "/contacts" - cath - <### [ "alice (Alice)", - "bob (Bob)" - ] - alice `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - testMergeContactExistingMember :: HasCallStack => FilePath -> IO () testMergeContactExistingMember = testChat3 aliceProfile bobProfile cathProfile $ @@ -2889,66 +2816,6 @@ testMergeContactExistingMember = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactExistingMemberWithContact :: HasCallStack => FilePath -> IO () -testMergeContactExistingMemberWithContact = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - createGroup3 "team" alice bob cath - - -- create contact, delete only for bob so he would send probe hash to member - bob ##> "/_create member contact #1 3" - bob <## "contact for member #team cath is created" - - bob ##> "/_invite member contact @3 text hi" - bob - <### [ "sent invitation to connect directly to member #team cath", - WithTime "@cath hi" - ] - cath - <### [ "#team bob is creating direct contact bob with you", - WithTime "bob> hi" - ] - concurrently_ - (bob <## "cath (Catherine): contact is connected") - (cath <## "bob (Bob): contact is connected") - bob <##> cath - - bob ##> "/_delete @3 notify=off" - bob <## "cath: contact is deleted" - - bob ##> "/contacts" - bob <### ["alice (Alice)"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - - -- contact connects, member is merged - bob ##> "/c" - inv' <- getInvitation bob - cath ##> ("/c " <> inv') - cath <## "confirmation sent!" - concurrentlyN_ - [ bob - <### [ "cath_1 (Catherine): contact is connected", - "contact and member are merged: cath_1, #team cath", - "use @cath to send messages" - ], - cath - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ] - ] - bob <##> cath - - bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () testMergeContactMultipleMembers = testChat3 aliceProfile bobProfile cathProfile $ @@ -2987,70 +2854,11 @@ testMergeContactMultipleMembers = bob `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] -testMergeContactExistingMembersAndContacts :: HasCallStack => FilePath -> IO () -testMergeContactExistingMembersAndContacts = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - bob ##> "/contact_merge off" - bob <## "ok" - - create2Groups3 "team" "club" alice bob cath - - bob ##> "/c" - inv' <- getInvitation bob - cath ##> ("/c " <> inv') - cath <## "confirmation sent!" - concurrently_ - (bob <## "cath_2 (Catherine): contact is connected") - (cath <## "bob_2 (Bob): contact is connected") - - bob `hasContactProfiles` ["alice", "bob", "cath", "cath", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob", "bob", "bob"] - - bob ##> "/contact_merge on" - bob <## "ok" - - bob ##> "/c" - inv'' <- getInvitation bob - cath ##> ("/c " <> inv'') - cath <## "confirmation sent!" - concurrentlyN_ - [ bob - <### [ "cath_3 (Catherine): contact is connected", - StartsWith "contact and member are merged: cath", - StartsWith "use @cath", - StartsWith "contact and member are merged: cath", - StartsWith "use @cath", - StartsWith "contact cath_3 is merged into cath", - StartsWith "use @cath" - ], - cath - <### [ "bob_3 (Bob): contact is connected", - StartsWith "contact and member are merged: bob", - StartsWith "use @bob", - StartsWith "contact and member are merged: bob", - StartsWith "use @bob", - StartsWith "contact bob_3 is merged into bob", - StartsWith "use @bob" - ] - ] - bob <##> cath - - bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] - cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - -testMergeMemberContact :: HasCallStack => FilePath -> IO () -testMergeMemberContact = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do - bob ##> "/contact_merge off" - bob <## "ok" - - createGroup3 "team" alice bob cath +testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeGroupLinkHostMultipleContacts = + testChat2 bobProfile cathProfile $ + \bob cath -> do + connectUsers bob cath bob ##> "/c" inv' <- getInvitation bob @@ -3060,61 +2868,40 @@ testMergeMemberContact = (bob <## "cath_1 (Catherine): contact is connected") (cath <## "bob_1 (Bob): contact is connected") - bob `hasContactProfiles` ["alice", "bob", "cath", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob", "bob"] + bob `hasContactProfiles` ["bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "bob", "bob"] - bob ##> "/contact_merge on" - bob <## "ok" - - -- bob and cath connect - bob ##> "/_create member contact #1 3" - bob <## "contact for member #team cath is created" - - bob ##> "/_invite member contact @4 text hi" - bob - <### [ "sent invitation to connect directly to member #team cath", - WithTime "@cath hi" - ] - cath - <### [ "#team bob is creating direct contact bob with you", - WithTime "bob> hi" - ] + bob ##> "/g party" + bob <## "group #party is created" + bob <## "to add members use /a party or /create link #party" + bob ##> "/create link #party" + gLink <- getGroupLink bob "party" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + bob <## "cath_2 (Catherine): accepting request to join group #party..." concurrentlyN_ [ bob - <### [ "cath (Catherine): contact is connected", - "contact cath_1 is merged into cath", - -- StartsWith "use @cath" - "use @cath to send messages" + <### [ "cath_2 (Catherine): contact is connected", + EndsWith "invited to group #party via your group link", + EndsWith "joined the group", + StartsWith "contact cath_2 is merged into cath", + StartsWith "use @cath" ], cath - <### [ "bob (Bob): contact is connected", - "contact bob_1 is merged into bob", - -- StartsWith "use @bob" - "use @bob to send messages" + <### [ "bob_2 (Bob): contact is connected", + "#party: you joined the group", + StartsWith "contact bob_2 is merged into bob", + StartsWith "use @bob" ] ] bob <##> cath bob ##> "/contacts" - bob <### ["alice (Alice)", "cath (Catherine)"] + bob <### ["cath (Catherine)", "cath_1 (Catherine)"] cath ##> "/contacts" - cath <### ["alice (Alice)", "bob (Bob)"] - bob `hasContactProfiles` ["alice", "bob", "cath"] - cath `hasContactProfiles` ["cath", "alice", "bob"] - - -- group messages work - alice #> "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath <# "#team alice> hello") - bob #> "#team hi there" - concurrently_ - (alice <# "#team bob> hi there") - (cath <# "#team bob> hi there") - cath #> "#team hey team" - concurrently_ - (alice <# "#team cath> hey team") - (bob <# "#team cath> hey team") + cath <### ["bob (Bob)", "bob_1 (Bob)"] + bob `hasContactProfiles` ["bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "bob", "bob"] testMemberContactMessage :: HasCallStack => FilePath -> IO () testMemberContactMessage = diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index d7cf682560..da6cbd156f 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -20,7 +20,6 @@ chatProfileTests = do it "use multiword profile names" testMultiWordProfileNames describe "user contact link" $ do it "create and connect via contact link" testUserContactLink - it "merge existing contact when connecting via contact link" testUserContactLinkMerge it "add contact link to profile" testProfileLink it "auto accept contact requests" testUserContactLinkAutoAccept it "deduplicate contact requests" testDeduplicateContactRequests @@ -219,39 +218,6 @@ testUserContactLink = alice @@@ [("@cath", lastChatFeature), ("@bob", "hey")] alice <##> cath -testUserContactLinkMerge :: HasCallStack => FilePath -> IO () -testUserContactLinkMerge = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - connectUsers alice bob - alice <##> bob - - alice ##> "/ad" - cLink <- getContactLink alice True - bob ##> ("/c " <> cLink) - bob <## "connection request sent!" - alice <## "bob_1 (Bob) wants to connect to you!" - alice <## "to accept: /ac bob_1" - alice <## "to reject: /rc bob_1 (the sender will NOT be notified)" - alice @@@ [("@bob", "hey"), ("<@bob_1", "")] - alice ##> "/ac bob_1" - alice <## "bob_1 (Bob): accepting contact request..." - concurrentlyN_ - [ alice - <### [ "bob_1 (Bob): contact is connected", - "contact bob_1 is merged into bob", - "use @bob to send messages" - ], - bob - <### [ "alice_1 (Alice): contact is connected", - "contact alice_1 is merged into alice", - "use @alice to send messages" - ] - ] - threadDelay 100000 - alice @@@ [("@bob", lastChatFeature)] - alice <##> bob - testProfileLink :: HasCallStack => FilePath -> IO () testProfileLink = testChat3 aliceProfile bobProfile cathProfile $ From bc26c23d589d8129bad31fbbe5b19d1ce3c8b2f8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 10:35:13 +0100 Subject: [PATCH 07/80] fix MobileTests (add single field JSON tag) --- tests/MobileTests.hs | 99 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 69c2207ff6..692dd5884e 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -65,71 +65,134 @@ mobileTests = do it "should convert invalid name to a valid name" testValidNameCApi noActiveUser :: LB.ByteString +noActiveUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) -noActiveUser = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"noActiveUser\":{}}}}}}}" + noActiveUserSwift #else -noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + noActiveUserTagged #endif +noActiveUserSwift :: LB.ByteString +noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" + +noActiveUserTagged :: LB.ByteString +noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + activeUserExists :: LB.ByteString +activeUserExists = #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + activeUserExistsSwift #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + activeUserExistsTagged #endif +activeUserExistsSwift :: LB.ByteString +activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + +activeUserExistsTagged :: LB.ByteString +activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + activeUser :: LB.ByteString +activeUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + activeUserSwift #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + activeUserTagged #endif +activeUserSwift :: LB.ByteString +activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + +activeUserTagged :: LB.ByteString +activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + chatStarted :: LB.ByteString +chatStarted = #if defined(darwin_HOST_OS) && defined(swiftJSON) -chatStarted = "{\"resp\":{\"chatStarted\":{}}}" + chatStartedSwift #else -chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" + chatStartedTagged #endif +chatStartedSwift :: LB.ByteString +chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" + +chatStartedTagged :: LB.ByteString +chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" + contactSubSummary :: LB.ByteString +contactSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -contactSubSummary = "{\"resp\":{\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" + contactSubSummarySwift #else -contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" + contactSubSummaryTagged #endif +contactSubSummarySwift :: LB.ByteString +contactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" + +contactSubSummaryTagged :: LB.ByteString +contactSubSummaryTagged = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" + memberSubSummary :: LB.ByteString +memberSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -memberSubSummary = "{\"resp\":{\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + memberSubSummarySwift #else -memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" + memberSubSummaryTagged #endif +memberSubSummarySwift :: LB.ByteString +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + +memberSubSummaryTagged :: LB.ByteString +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" + userContactSubSummary :: LB.ByteString +userContactSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + userContactSubSummarySwift #else -userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" + userContactSubSummaryTagged #endif +userContactSubSummarySwift :: LB.ByteString +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + +userContactSubSummaryTagged :: LB.ByteString +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" + pendingSubSummary :: LB.ByteString +pendingSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + pendingSubSummarySwift #else -pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" + pendingSubSummaryTagged #endif +pendingSubSummarySwift :: LB.ByteString +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + +pendingSubSummaryTagged :: LB.ByteString +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" + userJSON :: LB.ByteString userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" parsedMarkdown :: LB.ByteString +parsedMarkdown = #if defined(darwin_HOST_OS) && defined(swiftJSON) -parsedMarkdown = "{\"formattedText\":[{\"format\":{\"bold\":{}},\"text\":\"hello\"}]}" + parsedMarkdownSwift #else -parsedMarkdown = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" + parsedMarkdownTagged #endif +parsedMarkdownSwift :: LB.ByteString +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" + +parsedMarkdownTagged :: LB.ByteString +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" + testChatApiNoUser :: FilePath -> IO () testChatApiNoUser tmp = do let dbPrefix = tmp "1" From 73b3ea36481c11e28332c734f43c2ddeacc9c231 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Mon, 9 Oct 2023 21:04:29 +0800 Subject: [PATCH 08/80] Drop entropy patch (#3191) * Drop entropy patch We don't need the patch anymore. We can set -fDoNotGetEntropy these days to achieve the same. * remove entropy.patch --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- flake.nix | 8 ++++---- scripts/nix/entropy.patch | 30 ------------------------------ 2 files changed, 4 insertions(+), 34 deletions(-) delete mode 100644 scripts/nix/entropy.patch diff --git a/flake.nix b/flake.nix index f6a68246e2..44a2e287ea 100644 --- a/flake.nix +++ b/flake.nix @@ -287,7 +287,7 @@ extra-modules = [{ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-swift-json" @@ -297,7 +297,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-tagged-json" @@ -310,7 +310,7 @@ extra-modules = [{ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-swift-json" @@ -320,7 +320,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-tagged-json" diff --git a/scripts/nix/entropy.patch b/scripts/nix/entropy.patch deleted file mode 100644 index 2add42acb3..0000000000 --- a/scripts/nix/entropy.patch +++ /dev/null @@ -1,30 +0,0 @@ -diff --git a/cbits/random_initialized.c b/cbits/random_initialized.c -index 36ac968..ab708b0 100644 ---- a/cbits/random_initialized.c -+++ b/cbits/random_initialized.c -@@ -5,14 +5,6 @@ - #include - #include - --#ifdef HAVE_GETENTROPY --static int ensure_pool_initialized_getentropy() --{ -- char tmp; -- return getentropy(&tmp, sizeof(tmp)); --} --#endif -- - // Poll /dev/random to wait for randomness. This is a proxy for the /dev/urandom - // pool being initialized. - static int ensure_pool_initialized_poll() -@@ -45,10 +37,5 @@ static int ensure_pool_initialized_poll() - // Returns 0 on success, non-zero on failure. - int ensure_pool_initialized() - { --#ifdef HAVE_GETENTROPY -- if (ensure_pool_initialized_getentropy() == 0) -- return 0; --#endif -- - return ensure_pool_initialized_poll(); - } From 20995c691214404316d8279590af8e0dd3f1f998 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 14:35:44 +0100 Subject: [PATCH 09/80] core: 5.3.2.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 406f9aabaa..6ebfb03c3a 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.3.1.0 +version: 5.3.2.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 338346b655..4890d0e5c6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.3.1.0 +version: 5.3.2.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 09e5798d591cfddd90f9c98e70ccf0bd7a77633b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 16:56:42 +0100 Subject: [PATCH 10/80] ios: correctly parse json responses (#3193) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- apps/ios/SimpleXChat/API.swift | 5 ++- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8b45e45f95..18021e3079 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -114,11 +114,11 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; - 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */; }; - 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739892AC9D168009470A9 /* libgmp.a */; }; - 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398A2AC9D168009470A9 /* libffi.a */; }; - 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398B2AC9D168009470A9 /* libgmpxx.a */; }; - 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */; }; + 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739922AD44E2E009470A9 /* libgmp.a */; }; + 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */; }; + 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739942AD44E2E009470A9 /* libffi.a */; }; + 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */; }; + 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739962AD44E2E009470A9 /* libgmpxx.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -395,11 +395,11 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; - 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a"; sourceTree = ""; }; - 5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = ""; }; + 5CC739922AD44E2E009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a"; sourceTree = ""; }; + 5CC739942AD44E2E009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a"; sourceTree = ""; }; + 5CC739962AD44E2E009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */, + 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */, - 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */, - 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */, - 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */, + 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */, + 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */, + 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CC7398A2AC9D168009470A9 /* libffi.a */, - 5CC739892AC9D168009470A9 /* libgmp.a */, - 5CC7398B2AC9D168009470A9 /* libgmpxx.a */, - 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */, - 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */, + 5CC739942AD44E2E009470A9 /* libffi.a */, + 5CC739922AD44E2E009470A9 /* libgmp.a */, + 5CC739962AD44E2E009470A9 /* libgmpxx.a */, + 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */, + 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index e3d202c124..0d59fe55c7 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -132,8 +132,11 @@ public func chatResponse(_ s: String) -> ChatResponse { var type: String? var json: String? if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } if type == "apiChats" { if let jApiChats = jResp["apiChats"] as? NSDictionary, let user: UserRef = try? decodeObject(jApiChats["user"] as Any), From d764b3485ab7efaf24e7a0bc4a0481d1c33bc9d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 10 Oct 2023 00:10:47 +0800 Subject: [PATCH 11/80] desktop (windows): Github action for packaging (#3167) * desktop (windows): Github action for packaging * env * path changes --- .github/workflows/build.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c7a55b148..8785360693 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -293,4 +293,37 @@ jobs: body: | ${{ steps.windows_build.outputs.bin_hash }} + - name: Windows build desktop + id: windows_desktop_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + env: + SIMPLEX_CI_REPO_URL: ${{ secrets.SIMPLEX_CI_REPO_URL }} + shell: bash + run: | + scripts/desktop/build-lib-windows.sh + cd apps/multiplatform + ./gradlew packageMsi + path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') + echo "package_path=$path" >> $GITHUB_OUTPUT + echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Windows upload desktop package to release + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ steps.windows_desktop_build.outputs.package_path }} + asset_name: ${{ matrix.desktop_asset_name }} + tag: ${{ github.ref }} + + - name: Windows update desktop package hash + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + append_body: true + body: | + ${{ steps.windows_desktop_build.outputs.package_hash }} + # Windows / From c0e22d74c4df1e2c968f43c63f8d5efd7aa530bd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 17:30:48 +0100 Subject: [PATCH 12/80] core: 5.4.0.1 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 907a2a0686..6fed41b2ae 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.0 +version: 5.4.0.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 30c4c62dc1..4148f0ba83 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.0 +version: 5.4.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 3ddf7b26808dc5f5825e228f24fe819c29424383 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 18:03:03 +0100 Subject: [PATCH 13/80] ios: close database connections when app is terminating (#3188) * ios: close database connections when app is terminating * update * remove () * close when suspended too * additional check * fix * refactore * reset "terminating" flag --- apps/ios/Shared/Model/SuspendChat.swift | 28 ++++++++++++++++++++----- apps/ios/SimpleXChat/API.swift | 7 +++++++ apps/ios/SimpleXChat/AppGroup.swift | 8 +++++++ apps/ios/SimpleXChat/SimpleX.h | 1 + 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 58ed46a05a..1c8c32f8b9 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -19,7 +19,11 @@ let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds private func _suspendChat(timeout: Int) { - if ChatModel.ok { + // this is a redundant check to prevent logical errors, like the one fixed in this PR + let state = appStateGroupDefault.get() + if !state.canSuspend { + logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)") + } else if ChatModel.ok { appStateGroupDefault.set(.suspending) apiSuspendChat(timeoutMicroseconds: timeout * 1000000) let endTask = beginBGTask(chatSuspended) @@ -31,9 +35,7 @@ private func _suspendChat(timeout: Int) { func suspendChat() { suspendLockQueue.sync { - if appStateGroupDefault.get() != .stopped { - _suspendChat(timeout: appSuspendTimeout) - } + _suspendChat(timeout: appSuspendTimeout) } } @@ -45,15 +47,25 @@ func suspendBgRefresh() { } } +private var terminating = false + func terminateChat() { + logger.debug("terminateChat") suspendLockQueue.sync { switch appStateGroupDefault.get() { case .suspending: // suspend instantly if already suspending _chatSuspended() + // when apiSuspendChat is called with timeout 0, it won't send any events on suspension if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) } - case .stopped: () + chatCloseStore() + case .suspended: + chatCloseStore() + case .stopped: + chatCloseStore() default: + terminating = true + // the store will be closed in _chatSuspended when event is received _suspendChat(timeout: terminationTimeout) } } @@ -73,10 +85,14 @@ private func _chatSuspended() { if ChatModel.shared.chatRunning == true { ChatReceiver.shared.stop() } + if terminating { + chatCloseStore() + } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") + terminating = false suspendLockQueue.sync { appStateGroupDefault.set(appState) if ChatModel.ok { apiActivateChat() } @@ -85,6 +101,7 @@ func activateChat(appState: AppState = .active) { } func initChatAndMigrate(refreshInvitations: Bool = true) { + terminating = false let m = ChatModel.shared if (!m.chatInitialized) { do { @@ -97,6 +114,7 @@ func initChatAndMigrate(refreshInvitations: Bool = true) { } func startChatAndActivate() { + terminating = false logger.debug("DEBUGGING: startChatAndActivate") if ChatModel.shared.chatRunning == true { ChatReceiver.shared.start() diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index e3d202c124..fbf9d3b2ca 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -50,6 +50,13 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio return result } +public func chatCloseStore() { + let err = fromCString(chat_close_store(getChatCtrl())) + if err != "" { + logger.error("chatCloseStore error: \(err)") + } +} + public func resetChatCtrl() { chatController = nil migrationResult = nil diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index e09b957171..4943dbd4ea 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -80,6 +80,14 @@ public enum AppState: String { default: return false } } + + public var canSuspend: Bool { + switch self { + case .active: true + case .bgRefresh: true + default: false + } + } } public enum DBContainer: String { diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 67c2fa728c..644569c1ba 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -17,6 +17,7 @@ typedef void* chat_ctrl; // the last parameter is used to return the pointer to chat controller extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl); +extern char *chat_close_store(chat_ctrl ctl); extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); extern char *chat_recv_msg(chat_ctrl ctl); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); From 86c2f29920146f60ebf3d833f9ed175e5369cf60 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 9 Oct 2023 18:30:59 +0100 Subject: [PATCH 14/80] 5.3.2: ios 178, android 157, desktop 14 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 64 +++++++++++----------- apps/multiplatform/gradle.properties | 8 +-- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 20ba15e925..1cbe61dea0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -48,11 +48,6 @@ 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; - 5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */; }; - 5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */; }; - 5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625172AC1DE5900A21210 /* libgmpxx.a */; }; - 5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625182AC1DE5900A21210 /* libgmp.a */; }; - 5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625192AC1DE5900A21210 /* libffi.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; }; 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; }; @@ -119,6 +114,11 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; + 5CC739A12AD468E4009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399C2AD468E4009470A9 /* libgmpxx.a */; }; + 5CC739A22AD468E4009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399D2AD468E4009470A9 /* libffi.a */; }; + 5CC739A32AD468E4009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399E2AD468E4009470A9 /* libgmp.a */; }; + 5CC739A42AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399F2AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a */; }; + 5CC739A52AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739A02AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -293,11 +293,6 @@ 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = ""; }; 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = ""; }; - 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a"; sourceTree = ""; }; - 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a"; sourceTree = ""; }; - 5C5625172AC1DE5900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C5625182AC1DE5900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C5625192AC1DE5900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = ""; }; 5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; @@ -400,6 +395,11 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5CC7399C2AD468E4009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CC7399D2AD468E4009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CC7399E2AD468E4009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CC7399F2AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a"; sourceTree = ""; }; + 5CC739A02AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a"; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CC739A12AD468E4009470A9 /* libgmpxx.a in Frameworks */, + 5CC739A32AD468E4009470A9 /* libgmp.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */, - 5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */, - 5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */, - 5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */, - 5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5CC739A42AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a in Frameworks */, + 5CC739A22AD468E4009470A9 /* libffi.a in Frameworks */, + 5CC739A52AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C5625192AC1DE5900A21210 /* libffi.a */, - 5C5625182AC1DE5900A21210 /* libgmp.a */, - 5C5625172AC1DE5900A21210 /* libgmpxx.a */, - 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */, - 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */, + 5CC7399D2AD468E4009470A9 /* libffi.a */, + 5CC7399E2AD468E4009470A9 /* libgmp.a */, + 5CC7399C2AD468E4009470A9 /* libgmpxx.a */, + 5CC7399F2AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a */, + 5CC739A02AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a */, ); path = Libraries; sourceTree = ""; @@ -1486,7 +1486,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 178; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1507,7 +1507,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1528,7 +1528,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 178; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1549,7 +1549,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1608,7 +1608,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 178; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1621,7 +1621,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1640,7 +1640,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 178; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1653,7 +1653,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1672,7 +1672,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 178; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1696,7 +1696,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1718,7 +1718,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 174; + CURRENT_PROJECT_VERSION = 178; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1742,7 +1742,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.3.1; + MARKETING_VERSION = 5.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 0d047a7917..cd5a098f73 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.3.1 -android.version_code=154 +android.version_name=5.3.2 +android.version_code=157 -desktop.version_name=5.3.1 -desktop.version_code=11 +desktop.version_name=5.3.2 +desktop.version_code=14 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From a67b79952b467bb543ace2122feb46230ccbedcd Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 10 Oct 2023 21:19:04 +0400 Subject: [PATCH 15/80] core: connection plan api; check connection plan before connecting in terminal api (#3176) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 112 +++++++-- src/Simplex/Chat/Controller.hs | 61 +++++ .../M20231009_via_group_link_uri_hash.hs | 24 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/Store/Connections.hs | 9 +- src/Simplex/Chat/Store/Direct.hs | 48 ++-- src/Simplex/Chat/Store/Groups.hs | 43 ++++ src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 15 +- src/Simplex/Chat/Types.hs | 6 + src/Simplex/Chat/View.hs | 37 +++ tests/ChatTests/Direct.hs | 67 +++++ tests/ChatTests/Groups.hs | 237 ++++++++++++++++++ tests/ChatTests/Profiles.hs | 165 +++++++++++- 15 files changed, 784 insertions(+), 52 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4148f0ba83..5a84a1cde8 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -115,6 +115,7 @@ library Simplex.Chat.Migrations.M20230914_member_probes Simplex.Chat.Migrations.M20230926_contact_status Simplex.Chat.Migrations.M20231002_conn_initiated + Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 296abf0e2b..6f43f5c0f8 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -902,7 +902,7 @@ processChatCommand = \case filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do deleteFilesAndConns user filesInfo - when (isReady ct && contactActive ct && notify) $ + when (contactReady ct && contactActive ct && notify) $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) deleteAgentConnectionsAsync user contactConnIds @@ -1311,6 +1311,8 @@ processChatCommand = \case case conn'_ of Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' Nothing -> throwChatError CEConnectionIncognitoChangeProhibited + APIConnectPlan userId cReqUri -> withUserId userId $ \user -> withChatLock "connectPlan" . procCmd $ + CRConnectionPlan user <$> connectPlan user cReqUri APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send @@ -1323,11 +1325,16 @@ processChatCommand = \case pure $ CRSentConfirmation user APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq - Connect incognito cReqUri -> withUser $ \User {userId} -> - processChatCommand $ APIConnect userId incognito cReqUri - ConnectSimplex incognito -> withUser $ \user -> - -- [incognito] generate profile to send - connectViaContact user incognito adminContactReq + Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do + plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) + unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + processChatCommand $ APIConnect userId incognito aCReqUri + Connect _ Nothing -> throwChatError CEInvalidConnReq + ConnectSimplex incognito -> withUser $ \user@User {userId} -> do + let cReqUri = ACR SCMContact adminContactReq + plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) + unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + processChatCommand $ APIConnect userId incognito (Just cReqUri) DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> @@ -1423,7 +1430,7 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore' (`getUserContacts` user) - let cts = filter (\ct -> isReady ct && contactActive ct && directOrUsed ct) contacts + let cts = filter (\ct -> contactReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do (successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts @@ -1924,19 +1931,36 @@ processChatCommand = \case _ -> throwChatError $ CECommandError "not supported" connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> m ChatResponse connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do - let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case - (Just contact, _) -> pure $ CRContactAlreadyExists user contact - (_, xContactId_) -> procCmd $ do - let randomXContactId = XContactId <$> drgRandomBytes 16 - xContactId <- maybe randomXContactId pure xContactId_ - subMode <- chatReadVar subscriptionMode + let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + case groupLinkId of + -- contact address + Nothing -> + withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + (Just contact, _) -> pure $ CRContactAlreadyExists user contact + (_, xContactId_) -> procCmd $ do + let randomXContactId = XContactId <$> drgRandomBytes 16 + xContactId <- maybe randomXContactId pure xContactId_ + connect' Nothing cReqHash xContactId + -- group link + Just gLinkId -> + withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + (Just _contact, _) -> procCmd $ do + -- allow repeat contact request + newXContactId <- XContactId <$> drgRandomBytes 16 + connect' (Just gLinkId) cReqHash newXContactId + (_, xContactId_) -> procCmd $ do + let randomXContactId = XContactId <$> drgRandomBytes 16 + xContactId <- maybe randomXContactId pure xContactId_ + connect' (Just gLinkId) cReqHash xContactId + where + connect' groupLinkId cReqHash xContactId = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing dm <- directMessage (XContact profileToSend $ Just xContactId) + subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode - let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode toView $ CRNewContactConnection user conn pure $ CRSentInvitation user incognitoProfile @@ -1975,7 +1999,7 @@ processChatCommand = \case -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections contacts <- - filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct)) + filter (\ct -> contactReady ct && contactActive ct && not (contactConnIncognito ct)) <$> withStore' (`getUserContacts` user) user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') @@ -2046,10 +2070,6 @@ processChatCommand = \case g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> getGroupIdByName db user gName >>= getGroup db user runUpdateGroupProfile user g $ update p - isReady :: Contact -> Bool - isReady ct = - let s = connStatus $ ct.activeConn - in s == ConnReady || s == ConnSndReady withCurrentCall :: ContactId -> (User -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do @@ -2168,6 +2188,54 @@ processChatCommand = \case pure (gId, chatSettings) _ -> throwChatError $ CECommandError "not supported" processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings + connectPlan :: User -> AConnectionRequestUri -> m ConnectionPlan + connectPlan user (ACR SCMInvitation cReq) = do + withStore' (\db -> getConnectionEntityByConnReq db user cReq) >>= \case + Nothing -> pure $ CPInvitationLink ILPOk + Just (RcvDirectMsgConnection conn ct_) -> do + let Connection {connStatus, contactConnInitiated} = conn + if + | connStatus == ConnNew && contactConnInitiated -> + pure $ CPInvitationLink ILPOwnLink + | not (connReady conn) -> + pure $ CPInvitationLink (ILPConnecting ct_) + | otherwise -> case ct_ of + Just ct -> pure $ CPInvitationLink (ILPKnown ct) + Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" + Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + connectPlan user (ACR SCMContact cReq) = do + let CRContactUri ConnReqUriData {crClientData} = cReq + groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + case groupLinkId of + -- contact address + Nothing -> + withStore' (`getUserContactLinkByConnReq` cReq) >>= \case + Just _ -> pure $ CPContactAddress CAPOwnLink + Nothing -> do + let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + withStore' (\db -> getContactByConnReqHash db user cReqHash) >>= \case + Nothing -> pure $ CPContactAddress CAPOk + Just ct + | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnecting ct) + | otherwise -> pure $ CPContactAddress (CAPKnown ct) + -- group link + Just _ -> + withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReq) >>= \case + Just g -> pure $ CPGroupLink (GLPOwnLink g) + Nothing -> do + let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + ct_ <- withStore' $ \db -> getContactByConnReqHash db user cReqHash + gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHash + case (gInfo_, ct_) of + (Nothing, Nothing) -> pure $ CPGroupLink GLPOk + (Nothing, Just ct) + | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnecting gInfo_) + | otherwise -> pure $ CPGroupLink GLPOk + (Just gInfo@GroupInfo {membership}, _) + | not (memberActive membership) && not (memberRemoved membership) -> + pure $ CPGroupLink (GLPConnecting gInfo_) + | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) + | otherwise -> pure $ CPGroupLink GLPOk assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = @@ -4230,7 +4298,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn = Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct + let Contact {localDisplayName = c, activeConn = Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) @@ -4243,6 +4311,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do dm <- directMessage $ XGrpAcpt memberId connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do + setViaGroupLinkHash db groupId connId createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted @@ -5642,6 +5711,7 @@ chatCommandP = (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, + "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d859231faa..3466371da8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -338,6 +338,7 @@ data ChatCommand | APIAddContact UserId IncognitoEnabled | AddContact IncognitoEnabled | APISetConnectionIncognito Int64 IncognitoEnabled + | APIConnectPlan UserId AConnectionRequestUri | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) | Connect IncognitoEnabled (Maybe AConnectionRequestUri) | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) @@ -489,6 +490,7 @@ data ChatResponse | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} + | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} @@ -624,6 +626,64 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data ConnectionPlan + = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} + | CPContactAddress {contactAddressPlan :: ContactAddressPlan} + | CPGroupLink {groupLinkPlan :: GroupLinkPlan} + deriving (Show, Generic) + +instance ToJSON ConnectionPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CP" + +data InvitationLinkPlan + = ILPOk + | ILPOwnLink + | ILPConnecting {contact_ :: Maybe Contact} + | ILPKnown {contact :: Contact} + deriving (Show, Generic) + +instance ToJSON InvitationLinkPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "ILP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "ILP" + +data ContactAddressPlan + = CAPOk + | CAPOwnLink + | CAPConnecting {contact :: Contact} + | CAPKnown {contact :: Contact} + deriving (Show, Generic) + +instance ToJSON ContactAddressPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CAP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CAP" + +data GroupLinkPlan + = GLPOk + | GLPOwnLink {groupInfo :: GroupInfo} + | GLPConnecting {groupInfo_ :: Maybe GroupInfo} + | GLPKnown {groupInfo :: GroupInfo} + deriving (Show, Generic) + +instance ToJSON GroupLinkPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" + +connectionPlanOk :: ConnectionPlan -> Bool +connectionPlanOk = \case + CPInvitationLink ilp -> case ilp of + ILPOk -> True + ILPOwnLink -> True + _ -> False + CPContactAddress cap -> case cap of + CAPOk -> True + CAPOwnLink -> True + _ -> False + CPGroupLink glp -> case glp of + GLPOk -> True + GLPOwnLink _ -> True + _ -> False + newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -888,6 +948,7 @@ data ChatErrorType | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged + | CEConnectionPlan {connectionPlan :: ConnectionPlan} | CEInvalidConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} diff --git a/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs b/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs new file mode 100644 index 0000000000..41c9887a04 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs @@ -0,0 +1,24 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231009_via_group_link_uri_hash :: Query +m20231009_via_group_link_uri_hash = + [sql| +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); + +ALTER TABLE groups ADD COLUMN via_group_link_uri_hash BLOB; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(via_group_link_uri_hash); +|] + +down_m20231009_via_group_link_uri_hash :: Query +down_m20231009_via_group_link_uri_hash = + [sql| +DROP INDEX idx_groups_via_group_link_uri_hash; +ALTER TABLE groups DROP COLUMN via_group_link_uri_hash; + +DROP INDEX idx_connections_conn_req_inv; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index e88d83e42d..542acbbebd 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -117,7 +117,8 @@ CREATE TABLE groups( unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL), chat_ts TEXT, favorite INTEGER NOT NULL DEFAULT 0, - send_rcpts INTEGER, -- received + send_rcpts INTEGER, + via_group_link_uri_hash BLOB, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -736,3 +737,7 @@ CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups( + via_group_link_uri_hash +); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 383db3c59c..c9e846a81f 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Store.Connections ( getConnectionEntity, + getConnectionEntityByConnReq, getConnectionsToSubscribe, unsetConnectionToSubscribe, ) @@ -31,7 +32,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow') +import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Util (eitherToMaybe) @@ -152,6 +153,12 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound +getConnectionEntityByConnReq :: DB.Connection -> User -> ConnReqInvitation -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq db user cReq = do + connId_ <- maybeFirstRow fromOnly $ + DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv = ? LIMIT 1" (Only cReq) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ + getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 91243e2319..7227797193 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -25,6 +25,7 @@ module Simplex.Chat.Store.Direct createConnReqConnection, getProfileById, getConnReqContactXContactId, + getContactByConnReqHash, createDirectContact, deleteContactConnectionsAndFiles, deleteContact, @@ -137,32 +138,10 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId db user@User {userId} cReqHash = do - getContact' >>= \case + getContactByConnReqHash db user cReqHash >>= \case c@(Just _) -> pure (c, Nothing) Nothing -> (Nothing,) <$> getXContactId where - getContact' :: IO (Maybe Contact) - getContact' = - maybeFirstRow (toContact user) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - |] - (userId, cReqHash) getXContactId :: IO (Maybe XContactId) getXContactId = maybeFirstRow fromOnly $ @@ -171,6 +150,29 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) +getContactByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact) +getContactByConnReqHash db user@User {userId} cReqHash = + maybeFirstRow (toContact user) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, cReqHash, CSActive) + createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do createdAt <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 236031da94..20fb8c7217 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -31,9 +31,12 @@ module Simplex.Chat.Store.Groups getGroupAndMember, createNewGroup, createGroupInvitation, + setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, getGroupInfo, + getGroupInfoByUserContactLinkConnReq, + getGroupInfoByGroupLinkHash, updateGroupProfile, getGroupIdByName, getGroupMemberIdByName, @@ -405,6 +408,17 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ) pure $ Right incognitoLdn +setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () +setViaGroupLinkHash db groupId connId = + DB.execute + db + [sql| + UPDATE groups + SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) + WHERE group_id = ? + |] + (connId, groupId) + setGroupInvitationChatItemId :: DB.Connection -> User -> GroupId -> ChatItemId -> IO () setGroupInvitationChatItemId db User {userId} groupId chatItemId = do currentTs <- getCurrentTime @@ -1102,6 +1116,35 @@ getGroupInfo db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> ConnReqContact -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq db user cReq = do + groupId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT group_id + FROM user_contact_links + WHERE conn_req_contact = ? + |] + (Only cReq) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ + +getGroupInfoByGroupLinkHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash db user@User {userId, userContactId} groupLinkHash = do + groupId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? AND g.via_group_link_uri_hash = ? + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) + LIMIT 1 + |] + (userId, groupLinkHash, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ + getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId getGroupIdByName db User {userId} gName = ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $ diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 3ef68874b0..5c44b8cded 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -83,6 +83,7 @@ import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Chat.Migrations.M20231002_conn_initiated +import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -165,7 +166,8 @@ schemaMigrations = ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), - ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated) + ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), + ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index a577796810..5b5a6eb671 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Profiles deleteUserAddress, getUserAddress, getUserContactLinkById, + getUserContactLinkByConnReq, updateUserAddressAutoAccept, getProtocolServers, overwriteProtocolServers, @@ -86,7 +87,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (safeDecodeUtf8) +import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime @@ -440,6 +441,18 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) +getUserContactLinkByConnReq :: DB.Connection -> ConnReqContact -> IO (Maybe UserContactLink) +getUserContactLinkByConnReq db cReq = + maybeFirstRow toUserContactLink $ + DB.query + db + [sql| + SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE conn_req_contact = ? + |] + (Only cReq) + updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do link <- getUserAddress db user diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 529d2bf019..864ebd7227 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -206,6 +206,9 @@ directOrUsed ct@Contact {contactUsed} = anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed +contactReady :: Contact -> Bool +contactReady Contact {activeConn} = connReady activeConn + contactActive :: Contact -> Bool contactActive Contact {contactStatus} = contactStatus == CSActive @@ -1244,6 +1247,9 @@ data Connection = Connection } deriving (Eq, Show, Generic) +connReady :: Connection -> Bool +connReady Connection {connStatus} = connStatus == ConnReady || connStatus == ConnSndReady + authErrDisableCount :: Int authErrDisableCount = 10 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b981929efc..bb5e854cd2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -148,6 +148,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRVersionInfo info _ _ -> viewVersionInfo logLevel info CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c + CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] @@ -1223,6 +1224,41 @@ viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserPr | isJust customUserProfileId = ["connection " <> sShow pccConnId <> " changed to incognito"] | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] +viewConnectionPlan :: ConnectionPlan -> [StyledString] +viewConnectionPlan = \case + CPInvitationLink ilp -> case ilp of + ILPOk -> [invLink "ok to connect"] + ILPOwnLink -> [invLink "own link"] + ILPConnecting Nothing -> [invLink "connecting"] + ILPConnecting (Just ct) -> [invLink ("connecting to contact " <> ttyContact' ct)] + ILPKnown ct -> + [ invLink ("known contact " <> ttyContact' ct), + "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" + ] + where + invLink = ("invitation link: " <>) + CPContactAddress cap -> case cap of + CAPOk -> [ctAddr "ok to connect"] + CAPOwnLink -> [ctAddr "own address"] + CAPConnecting ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] + CAPKnown ct -> + [ ctAddr ("known contact " <> ttyContact' ct), + "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" + ] + where + ctAddr = ("contact address: " <>) + CPGroupLink glp -> case glp of + GLPOk -> [grpLink "ok to connect"] + GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] + GLPConnecting Nothing -> [grpLink "connecting"] + GLPConnecting (Just g) -> [grpLink ("connecting to group " <> ttyGroup' g)] + GLPKnown g -> + [ grpLink ("known group " <> ttyGroup' g), + "use " <> ttyToGroup g <> highlight' "" <> " to send messages" + ] + where + grpLink = ("group link: " <>) + viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated Contact {localDisplayName = n, profile = LocalProfile {fullName, contactLink}} @@ -1565,6 +1601,7 @@ viewChatError logLevel = \case CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] + CEConnectionPlan connectionPlan -> viewConnectionPlan connectionPlan CEInvalidConnReq -> viewInvalidConnReq CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> [ plain $ diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index a5fc7455c8..47333906bb 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -44,6 +44,10 @@ chatDirectTests = do describe "duplicate contacts" $ do it "duplicate contacts are separate (contacts don't merge)" testDuplicateContactsSeparate it "new contact is separate with multiple duplicate contacts (contacts don't merge)" testDuplicateContactsMultipleSeparate + describe "invitation link connection plan" $ do + it "invitation link ok to connect" testPlanInvitationLinkOk + it "own invitation link" testPlanInvitationLinkOwn + it "connecting via invitation link" testPlanInvitationLinkConnecting describe "SMP servers" $ do it "get and set SMP servers" testGetSetSMPServers it "test SMP server connection" testTestSMPServerConnection @@ -236,6 +240,69 @@ testDuplicateContactsMultipleSeparate = alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] +testPlanInvitationLinkOk :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOk = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" + + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection + + alice <##> bob + +testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/c" + inv <- getInvitation alice + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: own link" + + alice ##> ("/c " <> inv) + alice <## "confirmation sent!" + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_2 (Alice): contact is connected" + ] + + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection + + alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] + alice `send` "@alice_2 hi" + alice + <### [ WithTime "@alice_2 hi", + WithTime "alice_1> hi" + ] + alice `send` "@alice_1 hey" + alice + <### [ WithTime "@alice_1 hey", + WithTime "alice_2> hey" + ] + alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] + +testPlanInvitationLinkConnecting :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkConnecting tmp = do + inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: connecting" + testContactClear :: HasCallStack => FilePath -> IO () testContactClear = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9fb6ac7f9b..997beec4ea 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -57,6 +57,12 @@ chatGroupTests = do it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted it "group link member role" testGroupLinkMemberRole it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete + describe "group link connection plan" $ do + it "group link ok to connect; known group" testPlanGroupLinkOkKnown + it "group is known if host contact was deleted" testPlanHostContactDeletedGroupLinkKnown + it "own group link" testPlanGroupLinkOwn + it "connecting via group link" testPlanGroupLinkConnecting + it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin describe "group message errors" $ do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet @@ -2251,6 +2257,237 @@ testGroupLinkLeaveDelete = bob <## "alice (Alice)" bob <## "cath (Catherine)" +testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOkKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () +testPlanHostContactDeletedGroupLinkKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + alice <##> bob + threadDelay 500000 + bob ##> "/d alice" + bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + alice ##> ("/_connect plan 1 " <> gLink) + alice <## "group link: own link for group #team" + + alice ##> ("/c " <> gLink) + alice <## "connection request sent!" + alice <## "alice_1 (Alice): accepting request to join group #team..." + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_1 invited to group #team via your group link", + "#team: alice_1 joined the group", + "alice_2 (Alice): contact is connected", + "#team_1: you joined the group", + "contact alice_2 is merged into alice_1", + "use @alice_1 to send messages" + ] + alice `send` "#team 1" + alice + <### [ WithTime "#team 1", + WithTime "#team_1 alice_1> 1" + ] + alice `send` "#team_1 2" + alice + <### [ WithTime "#team_1 2", + WithTime "#team alice_1> 2" + ] + + alice ##> ("/_connect plan 1 " <> gLink) + alice <## "group link: own link for group #team" + + -- group works if merged contact is deleted + alice ##> "/d alice_1" + alice <## "alice_1: contact is deleted" + + alice `send` "#team 3" + alice + <### [ WithTime "#team 3", + WithTime "#team_1 alice_1> 3" + ] + alice `send` "#team_1 4" + alice + <### [ WithTime "#team_1 4", + WithTime "#team alice_1> 4" + ] + +testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () +testPlanGroupLinkConnecting tmp = do + gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + getGroupLink alice "team" GRMember True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + withTestChat tmp "alice" $ \alice -> do + alice + <### [ "1 group links active", + "#team: group is empty", + "bob (Bob): accepting request to join group #team..." + ] + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting" + + bob ##> ("/c " <> gLink) + bob <## "group link: connecting" + +testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () +testPlanGroupLinkLeaveRejoin = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> "/leave #team" + concurrentlyN_ + [ do + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group", + alice <## "#team: bob left the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + "bob_1 invited to group #team via your group link", + EndsWith "joined the group", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "#team_1: you joined the group", + "contact alice_1 is merged into alice", + "use @alice to send messages" + ] + ] + + alice #> "#team hi" + bob <# "#team_1 alice> hi" + bob #> "#team_1 hey" + alice <# "#team bob> hey" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index da6cbd156f..0d7683c4d7 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -28,6 +28,11 @@ chatProfileTests = do it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "auto-reply message" testAutoReplyMessage it "auto-reply message in incognito" testAutoReplyMessageInIncognito + describe "contact address connection plan" $ do + it "contact address ok to connect; known contact" testPlanAddressOkKnown + it "own contact address" testPlanAddressOwn + it "connecting via contact address" testPlanAddressConnecting + it "re-connect with deleted contact" testPlanAddressContactDeletedReconnected describe "incognito" $ do it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via contact address" testConnectIncognitoContactAddress @@ -369,7 +374,8 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ (alice <## "bob (Bob): contact is connected") bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice @@@ [("@bob", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -382,7 +388,8 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice <##> bob alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) @@ -440,7 +447,8 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile (alice <## "robert (Robert): contact is connected") bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice @@@ [("@robert", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":3", ""), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -455,7 +463,8 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice <##> bob alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) @@ -566,6 +575,154 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ ] ] +testPlanAddressOkKnown :: HasCallStack => FilePath -> IO () +testPlanAddressOkKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: ok to connect" + + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + +testPlanAddressOwn :: HasCallStack => FilePath -> IO () +testPlanAddressOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + alice ##> ("/_connect plan 1 " <> cLink) + alice <## "contact address: own address" + + alice ##> ("/c " <> cLink) + alice <## "connection request sent!" + alice <## "alice_1 (Alice) wants to connect to you!" + alice <## "to accept: /ac alice_1" + alice <## ("to reject: /rc alice_1 (the sender will NOT be notified)") + alice @@@ [("<@alice_1", ""), (":2","")] + alice ##> "/ac alice_1" + alice <## "alice_1 (Alice): accepting contact request..." + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_2 (Alice): contact is connected" + ] + + alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] + alice `send` "@alice_2 hi" + alice + <### [ WithTime "@alice_2 hi", + WithTime "alice_1> hi" + ] + alice `send` "@alice_1 hey" + alice + <### [ WithTime "@alice_1 hey", + WithTime "alice_2> hey" + ] + alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] + + alice ##> ("/_connect plan 1 " <> cLink) + alice <## "contact address: own address" + + alice ##> ("/c " <> cLink) + alice <## "alice_2 (Alice): contact already exists" + +testPlanAddressConnecting :: HasCallStack => FilePath -> IO () +testPlanAddressConnecting tmp = do + cLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/ad" + getContactLink alice True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + withTestChat tmp "alice" $ \alice -> do + alice <## "Your address is active! To show: /sa" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob @@@ [("@alice", "")] + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: connecting to contact alice" + + bob ##> ("/c " <> cLink) + bob <## "contact address: connecting to contact alice" + +testPlanAddressContactDeletedReconnected :: HasCallStack => FilePath -> IO () +testPlanAddressContactDeletedReconnected = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: ok to connect" + + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice_1 (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + alice #> "@bob hi" + bob <# "alice_1> hi" + bob #> "@alice_1 hey" + alice <# "bob> hey" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do From 4ecf94dfad04d93a4515a6e0b4c7338a0b8487fc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 09:50:11 +0100 Subject: [PATCH 16/80] core: move CLI notifications and active chat to view layer (for remote CLI) (#3196) * core: move CLI notifications to view layer (to allow notifications in remote CLI) * remove unused * refactor activeTo * move activeTo to ChatTerminal * refactor * move back * remove extension --- apps/simplex-bot-advanced/Main.hs | 2 +- apps/simplex-bot/Main.hs | 2 +- apps/simplex-broadcast-bot/Main.hs | 2 +- apps/simplex-chat/Main.hs | 2 +- apps/simplex-chat/Server.hs | 2 +- apps/simplex-directory-service/Main.hs | 2 +- src/Simplex/Chat.hs | 183 +++++++++------------- src/Simplex/Chat/Controller.hs | 25 +-- src/Simplex/Chat/Core.hs | 6 +- src/Simplex/Chat/Messages.hs | 6 +- src/Simplex/Chat/Mobile.hs | 2 +- src/Simplex/Chat/Store/Profiles.hs | 2 +- src/Simplex/Chat/Terminal.hs | 8 +- src/Simplex/Chat/Terminal/Input.hs | 38 +++-- src/Simplex/Chat/Terminal/Notification.hs | 3 +- src/Simplex/Chat/Terminal/Output.hs | 133 +++++++++++++++- src/Simplex/Chat/Types.hs | 2 - src/Simplex/Chat/View.hs | 2 +- tests/Bots/BroadcastTests.hs | 2 +- tests/Bots/DirectoryTests.hs | 2 +- tests/ChatClient.hs | 4 +- 21 files changed, 249 insertions(+), 181 deletions(-) diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 04d8e4ffa1..2af76d9961 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -24,7 +24,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig opts Nothing mySquaringBot + simplexChatCore terminalChatConfig opts mySquaringBot welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index d4ad9f9079..c24f9c251f 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -13,7 +13,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig opts Nothing $ + simplexChatCore terminalChatConfig opts $ chatBotRepl welcomeMessage $ \_contact msg -> pure $ case readMaybe msg :: Maybe Integer of Just n -> msg <> " * " <> msg <> " = " <> show (n * n) diff --git a/apps/simplex-broadcast-bot/Main.hs b/apps/simplex-broadcast-bot/Main.hs index 15bb743b56..3130437e0f 100644 --- a/apps/simplex-broadcast-bot/Main.hs +++ b/apps/simplex-broadcast-bot/Main.hs @@ -8,4 +8,4 @@ import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ broadcastBot opts + simplexChatCore terminalChatConfig (mkChatOpts opts) $ broadcastBot opts diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 8dd02623e2..f5d95e57f0 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -27,7 +27,7 @@ main = do welcome opts t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t - else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do + else simplexChatCore terminalChatConfig opts $ \user cc -> do r <- sendChatCmdStr cc chatCmd ts <- getCurrentTime tz <- getCurrentTimeZone diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 6f198340f8..d96350cd29 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -30,7 +30,7 @@ import UnliftIO.STM simplexChatServer :: ChatServerConfig -> ChatConfig -> ChatOpts -> IO () simplexChatServer srvCfg cfg opts = - simplexChatCore cfg opts Nothing . const $ runChatServer srvCfg + simplexChatCore cfg opts . const $ runChatServer srvCfg data ChatServerConfig = ChatServerConfig { chatPort :: ServiceName, diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 434e42d851..af9c9dd252 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -12,4 +12,4 @@ main :: IO () main = do opts@DirectoryOpts {directoryLog} <- welcomeGetOpts st <- restoreDirectoryStore directoryLog - simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ directoryService st opts + simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 6f43f5c0f8..5e2a231c97 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -183,13 +183,11 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key confirmMigrations pure ChatDatabase {chatStore, agentStore} -newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} sendToast = do +newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize} - sendNotification = fromMaybe (const $ pure ()) sendToast firstTime = dbNew chatStore - activeTo <- newTVarIO ActiveNone currentUser <- newTVarIO user servers <- agentServers config smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore @@ -197,7 +195,6 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg <- newTVarIO =<< liftIO drgNew inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize - notifyQ <- newTBQueueIO tbqSize subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty @@ -213,7 +210,34 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir contactMergeEnabled <- newTVarIO True - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile, contactMergeEnabled} + pure + ChatController + { firstTime, + currentUser, + smpAgent, + agentAsync, + chatStore, + chatStoreChanged, + idsDrg, + inputQ, + outputQ, + subscriptionMode, + chatLock, + sndFiles, + rcvFiles, + currentCalls, + config, + filesFolder, + expireCIThreads, + expireCIFlags, + cleanupManagerAsync, + timedItemThreads, + showLiveItems, + userXFTPFileConfig, + tempDirectory, + logFilePath = logFile, + contactMergeEnabled + } where configServers :: DefaultAgentServers configServers = @@ -260,7 +284,7 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do readTVarIO s >>= maybe (start s users) (pure . fst) where start s users = do - a1 <- async $ race_ notificationSubscriber agentSubscriber + a1 <- async agentSubscriber a2 <- if subConns then Just <$> async (subscribeUsers False users) @@ -376,7 +400,6 @@ processChatCommand = \case user <- withStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts storeServers user smpServers storeServers user xftpServers - setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user where @@ -402,7 +425,6 @@ processChatCommand = \case user' <- privateGetUser userId' validateUserPassword user user' viewPwd_ withStoreCtx' (Just "APISetActiveUser, setActiveUser") $ \db -> setActiveUser db userId' - setActive ActiveNone let user'' = user' {activeUser = True} asks currentUser >>= atomically . (`writeTVar` Just user'') pure $ CRActiveUser user'' @@ -532,7 +554,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user pagination search - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do (aci@(AChatItem cType dir _ ci), versions) <- withStore $ \db -> (,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId) @@ -546,7 +568,7 @@ processChatCommand = \case pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do - ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId + ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgNew_ unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct if isVoice mc && not (featureAllowed SCFVoice forUser ct) @@ -563,7 +585,6 @@ processChatCommand = \case ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) - setActive $ ActiveC c pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) where setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) @@ -614,7 +635,7 @@ processChatCommand = \case assertUserGroupRole gInfo GRAuthor send g where - send g@(Group gInfo@GroupInfo {groupId, membership, localDisplayName = gName} ms) + send g@(Group gInfo@GroupInfo {groupId, membership} ms) | isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles | otherwise = do @@ -629,7 +650,6 @@ processChatCommand = \case createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) - setActive $ ActiveG gName pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) @@ -734,7 +754,7 @@ processChatCommand = \case unzipMaybe3 _ = (Nothing, Nothing, Nothing) APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of CTDirect -> do - (ct@Contact {contactId, localDisplayName = c}, cci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct@Contact {contactId}, cci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId assertDirectAllowed user MDSnd ct XMsgUpdate_ case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do @@ -750,13 +770,12 @@ processChatCommand = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' - setActive $ ActiveC c pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do - Group gInfo@GroupInfo {groupId, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId + Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db user chatId assertUserGroupRole gInfo GRAuthor cci <- withStore $ \db -> getGroupChatItem db user chatId itemId case cci of @@ -773,7 +792,6 @@ processChatCommand = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - setActive $ ActiveG gName pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) _ -> throwChatError CEInvalidChatItemUpdate @@ -782,13 +800,12 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do - (ct@Contact {localDisplayName = c}, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertDirectAllowed user MDSnd ct XMsgDel_ (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgDel itemSharedMId Nothing) - setActive $ ActiveC c if featureAllowed SCFFullDelete forUser ct then deleteDirectCI user ct ci True False else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime @@ -898,7 +915,7 @@ processChatCommand = \case _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId + ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do deleteFilesAndConns user filesInfo @@ -910,7 +927,6 @@ processChatCommand = \case -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct withStore' $ \db -> deleteContact db user ct - unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId @@ -1698,11 +1714,10 @@ processChatCommand = \case LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search - setActive $ chatActiveTo chatName - pure $ CRChatItems user (aChatItems . chat $ chatResp) + pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast count) search - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) @@ -1714,10 +1729,10 @@ processChatCommand = \case chatItem <- withStore $ \db -> do chatRef <- getChatRefViaItemId db user itemId getAChatItem db user chatRef itemId - pure $ CRChatItems user ((: []) chatItem) + pure $ CRChatItems user Nothing ((: []) chatItem) ShowChatItem Nothing -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems ShowChatItemInfo chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName itemId <- getChatItemIdByText user chatRef msg @@ -2059,8 +2074,7 @@ processChatCommand = \case when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive delGroupChatItem :: User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> m ChatResponse - delGroupChatItem user gInfo@GroupInfo {localDisplayName = gName} ci msgId byGroupMember = do - setActive $ ActiveG gName + delGroupChatItem user gInfo ci msgId byGroupMember = do deletedTs <- liftIO getCurrentTime if groupFeatureAllowed SGFFullDelete gInfo then deleteGroupCI user gInfo ci True False byGroupMember deletedTs @@ -2117,7 +2131,6 @@ processChatCommand = \case let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) - setActive $ ActiveG localDisplayName sndContactCITimed :: Bool -> Contact -> Maybe Int -> m (Maybe CITimed) sndContactCITimed live = sndCITimed_ live . contactTimedTTL sndGroupCITimed :: Bool -> GroupInfo -> Maybe Int -> m (Maybe CITimed) @@ -2167,7 +2180,6 @@ processChatCommand = \case users <- withStore' getUsers unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $ throwChatError (CECantDeleteLastUser userId) - setActive ActiveNone deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) @@ -2867,17 +2879,16 @@ processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" - UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" + DOWN srv conns -> serverEvent srv conns CRContactsDisconnected + UP srv conns -> serverEvent srv conns CRContactsSubscribed SUSPENDED -> toView CRChatSuspended DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv@(SMPServer host _ _) conns event str = do - cs <- withStore' $ \db -> getConnectionsContacts db conns + serverEvent srv conns event = do + cs <- withStore' (`getConnectionsContacts` conns) toView $ event srv cs - showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host) processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m () processAgentMsgSndFile _corrId aFileId msg = @@ -3014,10 +3025,7 @@ processAgentMsgRcvFile _corrId aFileId msg = processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn user _ agentConnId END = withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case - RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do - toView $ CRContactAnotherClient user ct - whenUserNtfs user $ showToast (c <> "> ") "connected to another client" - unsetActive $ ActiveC c + RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct entity -> toView $ CRSubscriptionEnd user entity processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus @@ -3084,7 +3092,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () - Just ct@Contact {localDisplayName = c, contactId} -> case agentMsg of + Just ct@Contact {contactId} -> case agentMsg of INV (ACR _ cReq) -> -- [async agent commands] XGrpMemIntro continuation on receiving INV withCompletedCommand conn agentMsg $ \_ -> @@ -3169,9 +3177,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct - whenUserNtfs user $ do - setActive $ ActiveC c - showToast (c <> "> ") "connected" when (contactConnInitiated conn) $ do let Connection {groupLinkId} = conn doProbeContacts = isJust groupLinkId @@ -3248,7 +3253,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure () processGroupMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> m () - processGroupMessage agentMsg connEntity conn@Connection {connId, connectionCode} gInfo@GroupInfo {groupId, localDisplayName = gName, groupProfile, membership, chatSettings} m = case agentMsg of + processGroupMessage agentMsg connEntity conn@Connection {connId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of @@ -3338,15 +3343,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m forM_ description $ groupDescriptionChatItem gInfo m - whenUserNtfs user $ do - setActive $ ActiveG gName - showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} - whenGroupNtfs user gInfo $ do - setActive $ ActiveG gName - showToast ("#" <> gName) $ "member " <> m.localDisplayName <> " is connected" intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> @@ -3642,7 +3641,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do profileContactRequest invId chatVRange p xContactId_ = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact - CORRequest cReq@UserContactRequest {localDisplayName} -> do + CORRequest cReq -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case Just (UserContactLink {autoAccept}, groupId_, _) -> case autoAccept of @@ -3657,10 +3656,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo ct <- acceptContactRequestAsync user cReq profileMode toView $ CRAcceptingGroupJoinRequest user gInfo ct - _ -> do - toView $ CRReceivedContactRequest user cReq - whenUserNtfs user $ - showToast (localDisplayName <> "> ") "wants to connect to you" + _ -> toView $ CRReceivedContactRequest user cReq _ -> pure () incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m () @@ -3751,13 +3747,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m () - notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} ct_ = do + notifyMemberConnected gInfo m ct_ = do memberConnectedChatItem gInfo m toView $ CRConnectedToGroupMember user gInfo m ct_ - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - setActive $ ActiveG g - showToast ("#" <> g) $ "member " <> c <> " is connected" probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do @@ -3819,7 +3811,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newContentMessage ct@Contact {localDisplayName = c, contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do + newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let ExtMsgContent content fInv_ _ _ = mcExtMsgContent mc @@ -3832,23 +3824,18 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do if isVoice content && not (featureAllowed SCFVoice forContact ct) then do void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False - setActive $ ActiveC c else do let ExtMsgContent _ _ itemTTL live_ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct - ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live autoAcceptFile file_ - whenContactNtfs user ct $ do - showMsgToast (c <> "> ") content formattedText - setActive $ ActiveC c where newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}) - pure ci autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> m () autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do @@ -3907,7 +3894,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do + messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item @@ -3919,7 +3906,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content live Nothing toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - setActive $ ActiveC c where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -4006,7 +3992,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do e -> throwError e newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {localDisplayName = c, memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = do @@ -4036,20 +4022,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView cr createItem timed_ live = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m - ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live autoAcceptFile file_ - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText - setActive $ ActiveG g newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ groupMsgToView gInfo m ci {reactions} msgMeta - pure ci groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). @@ -4060,7 +4041,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createChatItemVersion db (chatItemId' ci) brokerTs mc updateGroupChatItem db user groupId ci content live Nothing toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -4079,7 +4059,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) updateGroupChatItem db user groupId ci content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) else messageError "x.msg.update: group member attempted to update a message of another member" @@ -4115,7 +4094,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processFileInvitation' ct fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize @@ -4124,13 +4103,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) - whenContactNtfs user ct $ do - showToast (c <> "> ") "wants to send a file" - setActive $ ActiveC c -- TODO remove once XFile is discontinued processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processGroupFileInvitation' gInfo m@GroupMember {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize @@ -4138,10 +4114,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False groupMsgToView gInfo m ci msgMeta - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" - setActive $ ActiveG g receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode) receiveInlineMode FileInvitation {fileSize, fileInline, fileDescr} mc_ chSize = case (fileInline, fileDescr) of @@ -4322,8 +4294,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} - whenContactNtfs user ct $ - showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" where sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' @@ -5470,29 +5440,20 @@ getCreateActiveUser st testView = do getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine -whenUserNtfs :: ChatMonad' m => User -> m () -> m () -whenUserNtfs User {showNtfs, activeUser} = when $ showNtfs || activeUser +userNtf :: User -> Bool +userNtf User {showNtfs, activeUser} = showNtfs || activeUser -whenContactNtfs :: ChatMonad' m => User -> Contact -> m () -> m () -whenContactNtfs user Contact {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) +chatNtf :: User -> ChatInfo c -> Bool +chatNtf user = \case + DirectChat ct -> contactNtf user ct + GroupChat g -> groupNtf user g + _ -> False -whenGroupNtfs :: ChatMonad' m => User -> GroupInfo -> m () -> m () -whenGroupNtfs user GroupInfo {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) +contactNtf :: User -> Contact -> Bool +contactNtf user Contact {chatSettings} = userNtf user && enableNtfs chatSettings -showMsgToast :: ChatMonad' m => Text -> MsgContent -> Maybe MarkdownList -> m () -showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_ - where - hideSecret :: FormattedText -> Text - hideSecret FormattedText {format = Just Secret} = "..." - hideSecret FormattedText {text} = text - -showToast :: ChatMonad' m => Text -> Text -> m () -showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ - -notificationSubscriber :: ChatMonad' m => m () -notificationSubscriber = do - ChatController {notifyQ, sendNotification} <- ask - forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification +groupNtf :: User -> GroupInfo -> Bool +groupNtf user GroupInfo {chatSettings} = userNtf user && enableNtfs chatSettings withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3466371da8..d97d59c5c5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -34,8 +34,7 @@ import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import Data.String import Data.Text (Text) -import Data.Time (NominalDiffTime) -import Data.Time.Clock (UTCTime) +import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) import GHC.Generics (Generic) import Language.Haskell.TH (Exp, Q, runIO) @@ -153,20 +152,10 @@ defaultInlineFilesConfig = receiveInstant = True -- allow receiving instant files, within receiveChunks limit } -data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName - deriving (Eq) - -chatActiveTo :: ChatName -> ActiveTo -chatActiveTo (ChatName cType name) = case cType of - CTDirect -> ActiveC name - CTGroup -> ActiveG name - _ -> ActiveNone - data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLiteStore} data ChatController = ChatController { currentUser :: TVar (Maybe User), - activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), @@ -175,8 +164,6 @@ data ChatController = ChatController idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, ChatResponse), - notifyQ :: TBQueue Notification, - sendNotification :: Notification -> IO (), subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), @@ -433,7 +420,7 @@ data ChatResponse | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat} - | CRChatItems {user :: User, chatItems :: [AChatItem]} + | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} @@ -1074,14 +1061,6 @@ mkChatError = ChatError . CEException . show chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError -setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -setActive to = asks activeTo >>= atomically . (`writeTVar` to) - -unsetActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) - where - unset a' = if a == a' then ActiveNone else a' - toView :: ChatMonad' m => ChatResponse -> m () toView event = do q <- asks outputQ diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 4af161ab41..870779cfda 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -14,8 +14,8 @@ import Simplex.Chat.Types import System.Exit (exitFailure) import UnliftIO.Async -simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} sendToast chat = +simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} chat = case logAgent of Just level -> do setLogLevel level @@ -28,7 +28,7 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core exitFailure run db@ChatDatabase {chatStore} = do u <- getCreateActiveUser chatStore testView - cc <- newChatController db (Just u) cfg opts sendToast + cc <- newChatController db (Just u) cfg opts runSimplexChat opts u cc chat runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 79463d2107..3831fad03e 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -50,8 +50,10 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord, Generic) -data ChatName = ChatName ChatType Text - deriving (Show) +data ChatName = ChatName {chatType :: ChatType, chatName :: Text} + deriving (Show, Generic) + +instance ToJSON ChatName where toEncoding = J.genericToEncoding J.defaultOptions chatTypeStr :: ChatType -> String chatTypeStr = \case diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 3d841f18c4..0a970d2c8e 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -196,7 +196,7 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do where initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix dbKey) Nothing + newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix dbKey) migrate createStore dbFile confirmMigrations = ExceptT $ (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 5b5a6eb671..fa573e4e60 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -87,7 +87,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) +import Simplex.Messaging.Util (safeDecodeUtf8) createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 0ef3d3bace..68aaa51318 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -15,7 +15,6 @@ import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Options import Simplex.Chat.Terminal.Input -import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Client (defaultNetworkConfig) @@ -40,10 +39,9 @@ terminalChatConfig = } simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChatTerminal cfg opts t = do - sendToast <- if muteNotifications opts then pure Nothing else Just <$> initializeNotifications - handle checkDBKeyError . simplexChatCore cfg opts sendToast $ \u cc -> do - ct <- newChatTerminal t +simplexChatTerminal cfg opts t = + handle checkDBKeyError . simplexChatCore cfg opts $ \u cc -> do + ct <- newChatTerminal t opts when (firstTime cc) . printToTerminal ct $ chatWelcome u runChatTerminal ct cc diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 8841f15ffd..0fd95cf680 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -57,14 +57,26 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do cmd = parseChatCommand bs unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand bs) cc - case r of - CRChatCmdError _ _ -> when (isMessage cmd) $ echo s - CRChatError _ _ -> when (isMessage cmd) $ echo s - _ -> pure () + processResp s cmd r printRespToTerminal ct cc False r startLiveMessage cmd r where echo s = printToTerminal ct [plain s] + processResp s cmd = \case + CRActiveUser _ -> setActive ct "" + CRChatItems u chatName_ _ -> whenCurrUser cc u $ mapM_ (setActive ct . chatActiveTo) chatName_ + CRNewChatItem u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo + CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo + CRChatItemDeleted u (AChatItem _ _ cInfo _) _ _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo + CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c + CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g + CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g + CRChatCmdError _ _ -> when (isMessage cmd) $ echo s + CRChatError _ _ -> when (isMessage cmd) $ echo s + CRCmdOk _ -> case cmd of + Right APIDeleteUser {} -> setActive ct "" + _ -> pure () + _ -> pure () isMessage = \case Right SendMessage {} -> True Right SendLiveMessage {} -> True @@ -134,7 +146,7 @@ runTerminalInput ct cc = withChatTerm ct $ do receiveFromTTY cc ct receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () -receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} = +receiveFromTTY cc@ChatController {inputQ, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) where processKey :: (Key, Modifiers) -> IO () @@ -153,11 +165,11 @@ receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@C when (inputString ts /= "" || isLive) $ atomically (submitInput live ts) >>= mapM_ (uncurry endLiveMessage) update key = do - ac <- readTVarIO activeTo + chatPrefix <- readTVarIO activeTo live <- isJust <$> readTVarIO liveMessageState ts <- readTVarIO termState user_ <- readTVarIO currentUser - ts' <- updateTermState user_ chatStore ac live (width termSize) key ts + ts' <- updateTermState user_ chatStore chatPrefix live (width termSize) key ts atomically $ writeTVar termState $! ts' endLiveMessage :: String -> LiveMessage -> IO () @@ -203,8 +215,8 @@ data AutoComplete | 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 +updateTermState :: Maybe User -> SQLiteStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState +updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of CharKey c | ms == mempty || ms == shiftKey -> pure $ insertChars $ charsWithContact [c] | ms == altKey && c == 'b' -> pure $ setPosition prevWordPos @@ -326,17 +338,13 @@ updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s, charsWithContact cs | live = cs | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" = - contactPrefix <> cs + chatPrefix <> cs | (s == ">" || s == "\\" || s == "!") && cs == " " = - cs <> contactPrefix + cs <> chatPrefix | 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) - contactPrefix = case ac of - ActiveNone -> "" - ActiveC c -> "@" <> T.unpack c <> " " - ActiveG g -> "#" <> T.unpack g <> " " backDeleteChar | p == 0 || null s = ts | p >= length s = ts' (init s, length s - 1) diff --git a/src/Simplex/Chat/Terminal/Notification.hs b/src/Simplex/Chat/Terminal/Notification.hs index 98031fe525..87bed5be1a 100644 --- a/src/Simplex/Chat/Terminal/Notification.hs +++ b/src/Simplex/Chat/Terminal/Notification.hs @@ -13,13 +13,14 @@ import qualified Data.Map as M import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Chat.Types import Simplex.Messaging.Util (catchAll_) import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getAppUserDataDirectory) import System.FilePath (combine) import System.Info (os) import System.Process (readCreateProcess, shell) +data Notification = Notification {title :: Text, text :: Text} + initializeNotifications :: IO (Notification -> IO ()) initializeNotifications = hideException <$> case os of diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index db6f16f3ca..556e4f792e 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -3,6 +3,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -14,13 +15,24 @@ import Control.Monad.Catch (MonadMask) import Control.Monad.Except import Control.Monad.Reader import Data.List (intercalate) +import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) -import Simplex.Chat (processChatCommand) +import Simplex.Chat (processChatCommand, chatNtf, contactNtf, groupNtf, userNtf) import Simplex.Chat.Controller -import Simplex.Chat.Messages hiding (NewChatItem (..)) +import Simplex.Chat.Markdown +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) +import Simplex.Chat.Options +import Simplex.Chat.Protocol (MsgContent (..), msgContentText) import Simplex.Chat.Styled +import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) +import Simplex.Chat.Types (Contact, GroupInfo (..), User (..), UserContactRequest (..)) import Simplex.Chat.View +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import System.IO (IOMode (..), hPutStrLn, withFile) import System.Mem.Weak (Weak) @@ -34,7 +46,9 @@ data ChatTerminal = ChatTerminal termSize :: Size, liveMessageState :: TVar (Maybe LiveMessage), nextMessageRow :: TVar Int, - termLock :: TMVar () + termLock :: TMVar (), + sendNotification :: Maybe (Notification -> IO ()), + activeTo :: TVar String } data TerminalState = TerminalState @@ -79,16 +93,28 @@ instance WithTerminal VirtualTerminal where withChatTerm :: (MonadIO m, MonadMask m) => ChatTerminal -> (forall t. WithTerminal t => TerminalT t m a) -> m a withChatTerm ChatTerminal {termDevice = TerminalDevice t} action = withTerm t $ runTerminalT action -newChatTerminal :: WithTerminal t => t -> IO ChatTerminal -newChatTerminal t = do +newChatTerminal :: WithTerminal t => t -> ChatOpts -> IO ChatTerminal +newChatTerminal t opts = do termSize <- withTerm t . runTerminalT $ getWindowSize let lastRow = height termSize - 1 termState <- newTVarIO mkTermState liveMessageState <- newTVarIO Nothing termLock <- newTMVarIO () nextMessageRow <- newTVarIO lastRow + sendNotification <- if muteNotifications opts then pure Nothing else Just <$> initializeNotifications + activeTo <- newTVarIO "" -- threadDelay 500000 -- this delay is the same as timeout in getTerminalSize - return ChatTerminal {termDevice = TerminalDevice t, termState, termSize, liveMessageState, nextMessageRow, termLock} + pure + ChatTerminal + { termDevice = TerminalDevice t, + termState, + termSize, + liveMessageState, + nextMessageRow, + termLock, + sendNotification, + activeTo + } mkTermState :: TerminalState mkTermState = @@ -122,6 +148,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems responseString cc liveItems r >>= printResp + responseNotification ct cc r where markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = case (muted chat chatDir, itemStatus) of @@ -132,6 +159,100 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s +responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () +responseNotification t@ChatTerminal {sendNotification} cc = \case + CRNewChatItem u (AChatItem _ SMDRcv cInfo ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> + when (chatNtf u cInfo) $ do + whenCurrUser cc u $ setActiveChat t cInfo + case (cInfo, chatDir) of + (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) + (GroupChat g, CIGroupRcv m) -> sendNtf (fromGroup_ g m, text) + _ -> pure () + where + text = msgText mc formattedText + CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ChatItem {content = CIRcvMsgContent _}) -> + whenCurrUser cc u $ when (chatNtf u cInfo) $ setActiveChat t cInfo + CRContactConnected u ct _ -> when (contactNtf u ct) $ do + whenCurrUser cc u $ setActiveContact t ct + sendNtf (viewContactName ct <> "> ", "connected") + CRContactAnotherClient u ct -> do + whenCurrUser cc u $ unsetActiveContact t ct + when (contactNtf u ct) $ sendNtf (viewContactName ct <> "> ", "connected to another client") + CRContactsDisconnected srv _ -> serverNtf srv "disconnected" + CRContactsSubscribed srv _ -> serverNtf srv "connected" + CRReceivedGroupInvitation u g ct _ _ -> + when (contactNtf u ct) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") + CRUserJoinedGroup u g _ -> when (groupNtf u g) $ do + whenCurrUser cc u $ setActiveGroup t g + sendNtf ("#" <> viewGroupName g, "you are connected to group") + CRJoinedGroupMember u g m -> + when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + CRConnectedToGroupMember u g m _ -> + when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> + when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") + _ -> pure () + where + sendNtf = maybe (\_ -> pure ()) (. uncurry Notification) sendNotification + serverNtf (SMPServer host _ _) str = sendNtf ("server " <> str, safeDecodeUtf8 $ strEncode host) + +msgText :: MsgContent -> Maybe MarkdownList -> Text +msgText (MCFile _) _ = "wants to send a file" +msgText mc md_ = maybe (msgContentText mc) (mconcat . map hideSecret) md_ + where + hideSecret :: FormattedText -> Text + hideSecret FormattedText {format = Just Secret} = "..." + hideSecret FormattedText {text} = text + +chatActiveTo :: ChatName -> String +chatActiveTo (ChatName cType name) = case cType of + CTDirect -> T.unpack $ "@" <> viewName name <> " " + CTGroup -> T.unpack $ "#" <> viewName name <> " " + _ -> "" + +chatInfoActiveTo :: ChatInfo c -> String +chatInfoActiveTo = \case + DirectChat c -> contactActiveTo c + GroupChat g -> groupActiveTo g + _ -> "" + +contactActiveTo :: Contact -> String +contactActiveTo c = T.unpack $ "@" <> viewContactName c <> " " + +groupActiveTo :: GroupInfo -> String +groupActiveTo g = T.unpack $ "#" <> viewGroupName g <> " " + +setActiveChat :: ChatTerminal -> ChatInfo c -> IO () +setActiveChat t = setActive t . chatInfoActiveTo + +setActiveContact :: ChatTerminal -> Contact -> IO () +setActiveContact t = setActive t . contactActiveTo + +setActiveGroup :: ChatTerminal -> GroupInfo -> IO () +setActiveGroup t = setActive t . groupActiveTo + +setActive :: ChatTerminal -> String -> IO () +setActive ChatTerminal {activeTo} to = atomically $ writeTVar activeTo to + +unsetActiveContact :: ChatTerminal -> Contact -> IO () +unsetActiveContact t = unsetActive t . contactActiveTo + +unsetActiveGroup :: ChatTerminal -> GroupInfo -> IO () +unsetActiveGroup t = unsetActive t . groupActiveTo + +unsetActive :: ChatTerminal -> String -> IO () +unsetActive ChatTerminal {activeTo} to' = atomically $ modifyTVar activeTo unset + where + unset to = if to == to' then "" else to + +whenCurrUser :: ChatController -> User -> IO () -> IO () +whenCurrUser cc u a = do + u_ <- readTVarIO $ currentUser cc + when (sameUser u u_) a + where + sameUser User {userId = uId} = maybe False $ \User {userId} -> userId == uId + printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> ChatResponse -> IO () printRespToTerminal ct cc liveItems r = responseString cc liveItems r >>= printToTerminal ct diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 864ebd7227..de56baad92 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1424,8 +1424,6 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -data Notification = Notification {title :: Text, text :: Text} - textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index bb5e854cd2..f60b7cd82f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -103,7 +103,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts tz <> viewItemReactions item - CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems + CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index ae2d67c7f0..ed0b9e069a 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -26,7 +26,7 @@ withBroadcastBot :: BroadcastBotOpts -> IO () -> IO () withBroadcastBot opts test = bracket (forkIO bot) killThread (\_ -> threadDelay 500000 >> test) where - bot = simplexChatCore testCfg (mkChatOpts opts) Nothing $ broadcastBot opts + bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing} diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0e315190c5..3e1c32a6f7 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -827,7 +827,7 @@ runDirectory cfg opts@DirectoryOpts {directoryLog} action = do threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) where - bot st = simplexChatCore cfg (mkChatOpts opts) Nothing $ directoryService st opts + bot st = simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 7da5263253..aae3b5c4c7 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -161,8 +161,8 @@ startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefi startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure - ct <- newChatTerminal t - cc <- newChatController db (Just user) cfg opts Nothing -- no notifications + ct <- newChatTerminal t opts + cc <- newChatController db (Just user) cfg opts chatAsync <- async . runSimplexChat opts user cc . const $ runChatTerminal ct atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO From b03fe183bb2dece22e9d7cbdfada70e85e9a774c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:26:44 +0400 Subject: [PATCH 17/80] tests: modify testMemberContactInvitedConnectionReplaced to not rely on chat item order, print output (#3198) --- tests/ChatClient.hs | 2 ++ tests/ChatTests/Groups.hs | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index aae3b5c4c7..fae460e908 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -210,6 +210,8 @@ withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +-- enable output for specific chat controller, use like this: +-- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a withTestOutput cc runTest = runTest cc {printOutput = True} diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 997beec4ea..6578df84c0 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3252,9 +3252,9 @@ testMemberContactProhibitedRepeatInv = testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \c -> withTestOutput c $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -3277,7 +3277,9 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob ##> "/_get chat @2 count=100" + items <- chat <$> getTermLine bob + items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From bca9473d7704b3476452144b58a96d7ffb9e8728 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:10:38 +0100 Subject: [PATCH 18/80] core: settings to hide member messages, to show only reply (and mention) notifications (#3190) * core: settings to hide member messages, to show only reply (and mention) notifications * change type for showMessages * commands for member settings * member and notification settings * test * take member settings into account when showing messages and notifications * fix to show sent messages * store blocked items * types * rename to MFMentions --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 132 ++++++++++-------- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Messages.hs | 37 +++-- src/Simplex/Chat/Messages/CIContent.hs | 14 +- .../Migrations/M20231010_member_settings.hs | 18 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 5 + src/Simplex/Chat/Store/Connections.hs | 8 +- src/Simplex/Chat/Store/Groups.hs | 51 ++++--- src/Simplex/Chat/Store/Messages.hs | 96 ++++++++----- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Shared.hs | 6 +- src/Simplex/Chat/Terminal/Output.hs | 36 ++--- src/Simplex/Chat/Types.hs | 62 +++++++- src/Simplex/Chat/View.hs | 121 ++++++++++------ tests/ChatTests/Direct.hs | 71 +++++++++- tests/ChatTests/Groups.hs | 1 - 18 files changed, 441 insertions(+), 227 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231010_member_settings.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 5a84a1cde8..b431b0ddf3 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -116,6 +116,7 @@ library Simplex.Chat.Migrations.M20230926_contact_status Simplex.Chat.Migrations.M20231002_conn_initiated Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash + Simplex.Chat.Migrations.M20231010_member_settings Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 5e2a231c97..b7421d9366 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -800,7 +800,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do - (ct, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do @@ -812,7 +812,7 @@ processChatCommand = \case (CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete CTGroup -> do Group gInfo ms <- withStore $ \db -> getGroup db user chatId - ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> getGroupChatItem db user chatId itemId + CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}} <- withStore $ \db -> getGroupChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do @@ -824,7 +824,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteMemberChatItem gId mId itemId -> withUser $ \user -> withChatLock "deleteChatItem" $ do Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db user gId - ci@(CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) <- withStore $ \db -> getGroupChatItem db user gId itemId + CChatItem _ ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}} <- withStore $ \db -> getGroupChatItem db user gId itemId case (chatDir, itemSharedMsgId) of (CIGroupRcv GroupMember {groupMemberId, memberRole, memberId}, Just itemSharedMId) -> do when (groupMemberId /= mId) $ throwChatError CEInvalidChatItemDelete @@ -1178,7 +1178,7 @@ processChatCommand = \case ct <- getContact db user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct - withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) + withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (chatHasNtfs chatSettings) ok user CTGroup -> do ms <- withStore $ \db -> do @@ -1186,9 +1186,17 @@ processChatCommand = \case liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) ok user _ -> pure $ chatCmdError (Just user) "not supported" + APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do + m <- withStore $ \db -> do + liftIO $ updateGroupMemberSettings db user gId gMemberId settings + getGroupMember db user gId gMemberId + when (memberActive m) $ forM_ (memberConnId m) $ \connId -> do + let ntfOn = showMessages $ memberSettings m + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) + ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId @@ -1283,6 +1291,11 @@ processChatCommand = \case _ -> throwChatError CEGroupMemberNotActive SetShowMessages cName ntfOn -> updateChatSettings cName (\cs -> cs {enableNtfs = ntfOn}) SetSendReceipts cName rcptsOn_ -> updateChatSettings cName (\cs -> cs {sendRcpts = rcptsOn_}) + SetShowMemberMessages gName mName showMessages -> withUser $ \user -> do + (gId, mId) <- getGroupAndMemberId user gName mName + m <- withStore $ \db -> getGroupMember db user gId mId + let settings = (memberSettings m) {showMessages} + processChatCommand $ APISetMemberSettings gId mId settings ContactInfo cName -> withContactName cName APIContactInfo ShowGroupInfo gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName @@ -2073,7 +2086,7 @@ processChatCommand = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItem :: User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> m ChatResponse + delGroupChatItem :: MsgDirectionI d => User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Maybe GroupMember -> m ChatResponse delGroupChatItem user gInfo ci msgId byGroupMember = do deletedTs <- liftIO getCurrentTime if groupFeatureAllowed SGFFullDelete gInfo @@ -2813,10 +2826,10 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do waitChatStarted case cType of CTDirect -> do - (ct, ci) <- withStoreCtx (Just "deleteTimedItem, getContact ...") $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId deleteDirectCI user ct ci True True >>= toView CTGroup -> do - (gInfo, ci) <- withStoreCtx (Just "deleteTimedItem, getGroupInfo ...") $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId + (gInfo, CChatItem _ ci) <- withStore $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime deleteGroupCI user gInfo ci True True Nothing deletedTs >>= toView _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" @@ -3335,7 +3348,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do updateGroupMemberStatus db userId membership GSMemConnected -- possible improvement: check for each pending message, requires keeping track of connection state unless (connDisabled conn) $ sendPendingGroupMessages user m conn - withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ enableNtfs chatSettings + withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} @@ -3932,7 +3945,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do deleteRcvChatItem `catchCINotFound` (toView . CRChatItemDeletedNotFound user ct) where deleteRcvChatItem = do - ci@(CChatItem msgDir _) <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId + CChatItem msgDir ci <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId case msgDir of SMDRcv -> if featureAllowed SCFFullDelete forContact ct @@ -4013,21 +4026,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createItem timed_ live | groupFeatureAllowed SGFFullDelete gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta CIRcvModerated Nothing timed_ False - ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo (CChatItem SMDRcv ci) moderator moderatedAt - toView $ CRNewChatItem user ci' + ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt + toView $ CRNewChatItem user $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' | otherwise = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent content) (snd <$> file_) timed_ False - cr <- markGroupCIDeleted user gInfo (CChatItem SMDRcv ci) createdByMsgId False (Just moderator) moderatedAt - toView cr + toView =<< markGroupCIDeleted user gInfo ci createdByMsgId False (Just moderator) moderatedAt createItem timed_ live = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live - autoAcceptFile file_ + when (showMessages $ memberSettings m) $ autoAcceptFile file_ newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live + ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo m ci {reactions} msgMeta + groupMsgToView gInfo m ci' {reactions} msgMeta groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = @@ -4039,7 +4052,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc - updateGroupChatItem db user groupId ci content live Nothing + ci' <- updateGroupChatItem db user groupId ci content live Nothing + blockedMember m ci' $ markGroupChatItemBlocked db user gInfo ci' toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') where MsgMeta {broker = (_, brokerTs)} = msgMeta @@ -4068,7 +4082,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do let msgMemberId = fromMaybe memberId sndMemberId_ withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case - Right ci@(CChatItem _ ChatItem {chatDir}) -> case chatDir of + Right (CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of CIGroupRcv mem | sameMemberId memberId mem && msgMemberId == memberId -> delete ci Nothing >>= toView | otherwise -> deleteMsg mem ci @@ -4078,7 +4092,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e | otherwise -> withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs where - deleteMsg :: GroupMember -> CChatItem 'CTGroup -> m () + deleteMsg :: MsgDirectionI d => GroupMember -> ChatItem 'CTGroup d -> m () deleteMsg mem ci = case sndMemberId_ of Just sndMemberId | sameMemberId sndMemberId mem -> checkRole mem $ delete ci (Just m) >>= toView @@ -4088,6 +4102,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a + delete :: MsgDirectionI d => ChatItem 'CTGroup d -> Maybe GroupMember -> m ChatResponse delete ci byGroupMember | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCI user gInfo ci False False byGroupMember brokerTs | otherwise = markGroupCIDeleted user gInfo ci msgId False byGroupMember brokerTs @@ -4113,7 +4128,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False - groupMsgToView gInfo m ci msgMeta + ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci + groupMsgToView gInfo m ci' msgMeta + + blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) + blockedMember m ci blockedCI + | showMessages (memberSettings m) = pure ci + | otherwise = blockedCI receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode) receiveInlineMode FileInvitation {fileSize, fileInline, fileDescr} mc_ chSize = case (fileInline, fileDescr) of @@ -4632,7 +4653,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro gInfo@GroupInfo {chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do + xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -4652,7 +4673,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" where - createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation subMode + createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do @@ -4675,7 +4696,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of @@ -4690,8 +4711,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- [incognito] send membership incognito profile, create direct connection as incognito dm <- directMessage $ XGrpMemInfo membership.memberId (fromLocalProfile $ memberProfile membership) -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm subMode - directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm subMode + groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode @@ -5215,20 +5236,22 @@ mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs cur meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} -deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool -> Bool -> m ChatResponse -deleteDirectCI user ct ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do +deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse +deleteDirectCI user ct ci@ChatItem {file} byUser timed = do deleteCIFile user file withStoreCtx' (Just "deleteDirectCI, deleteDirectChatItem") $ \db -> deleteDirectChatItem db user ct ci - pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) Nothing byUser timed + pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDirection (DirectChat ct) ci) Nothing byUser timed -deleteGroupCI :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse -deleteGroupCI user gInfo ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed byGroupMember_ deletedTs = do +deleteGroupCI :: (ChatMonad m, MsgDirectionI d) => User -> GroupInfo -> ChatItem 'CTGroup d -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse +deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedTs = do deleteCIFile user file toCi <- withStoreCtx' (Just "deleteGroupCI, deleteGroupChatItem ...") $ \db -> case byGroupMember_ of Nothing -> deleteGroupChatItem db user gInfo ci $> Nothing Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs - pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi byUser timed + pure $ CRChatItemDeleted user (gItem ci) (gItem <$> toCi) byUser timed + where + gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile user file_ = @@ -5236,25 +5259,21 @@ deleteCIFile user file_ = fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds -markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> UTCTime -> m ChatResponse -markDirectCIDeleted user ct@Contact {contactId} ci@(CChatItem _ ChatItem {file}) msgId byUser deletedTs = do +markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse +markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do cancelCIFile user file - toCi <- withStore $ \db -> do - liftIO $ markDirectChatItemDeleted db user ct ci msgId deletedTs - getDirectChatItem db user contactId (cchatItemId ci) - pure $ CRChatItemDeleted user (ctItem ci) (Just $ ctItem toCi) byUser False + ci' <- withStore' $ \db -> markDirectChatItemDeleted db user ct ci msgId deletedTs + pure $ CRChatItemDeleted user (ctItem ci) (Just $ ctItem ci') byUser False where - ctItem (CChatItem msgDir ci') = AChatItem SCTDirect msgDir (DirectChat ct) ci' + ctItem = AChatItem SCTDirect msgDirection (DirectChat ct) -markGroupCIDeleted :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse -markGroupCIDeleted user gInfo@GroupInfo {groupId} ci@(CChatItem _ ChatItem {file}) msgId byUser byGroupMember_ deletedTs = do +markGroupCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse +markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ deletedTs = do cancelCIFile user file - toCi <- withStore $ \db -> do - liftIO $ markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_ deletedTs - getGroupChatItem db user groupId (cchatItemId ci) - pure $ CRChatItemDeleted user (gItem ci) (Just $ gItem toCi) byUser False + ci' <- withStore' $ \db -> markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_ deletedTs + pure $ CRChatItemDeleted user (gItem ci) (Just $ gItem ci') byUser False where - gItem (CChatItem msgDir ci') = AChatItem SCTGroup msgDir (GroupChat gInfo) ci' + gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () cancelCIFile user file_ = @@ -5440,21 +5459,6 @@ getCreateActiveUser st testView = do getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine -userNtf :: User -> Bool -userNtf User {showNtfs, activeUser} = showNtfs || activeUser - -chatNtf :: User -> ChatInfo c -> Bool -chatNtf user = \case - DirectChat ct -> contactNtf user ct - GroupChat g -> groupNtf user g - _ -> False - -contactNtf :: User -> Contact -> Bool -contactNtf user Contact {chatSettings} = userNtf user && enableNtfs chatSettings - -groupNtf :: User -> GroupInfo -> Bool -groupNtf user GroupInfo {chatSettings} = userNtf user && enableNtfs chatSettings - withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = asks currentUser @@ -5492,9 +5496,12 @@ withAgent action = chatCommandP :: Parser ChatCommand chatCommandP = choice - [ "/mute " *> ((`SetShowMessages` False) <$> chatNameP), - "/unmute " *> ((`SetShowMessages` True) <$> chatNameP), + [ "/mute " *> ((`SetShowMessages` MFNone) <$> chatNameP), + "/unmute " *> ((`SetShowMessages` MFAll) <$> chatNameP), + "/unmute mentions " *> ((`SetShowMessages` MFMentions) <$> chatNameP), "/receipts " *> (SetSendReceipts <$> chatNameP <* " " <*> ((Just <$> onOffP) <|> ("default" $> Nothing))), + "/block #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), + "/unblock #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), "/create user " *> (CreateActiveUser <$> newUserP), "/users" $> ListUsers, @@ -5598,6 +5605,7 @@ chatCommandP = ("/network" <|> "/net") $> APIGetNetworkConfig, "/reconnect" $> ReconnectAllServers, "/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP), + "/_member settings #" *> (APISetMemberSettings <$> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info #" *> (APIGroupInfo <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d97d59c5c5..af9f34d2d4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -288,6 +288,7 @@ data ChatCommand | APIGetNetworkConfig | ReconnectAllServers | APISetChatSettings ChatRef ChatSettings + | APISetMemberSettings GroupId GroupMemberId GroupMemberSettings | APIContactInfo ContactId | APIGroupInfo GroupId | APIGroupMemberInfo GroupId GroupMemberId @@ -303,8 +304,9 @@ data ChatCommand | APIVerifyGroupMember GroupId GroupMemberId (Maybe Text) | APIEnableContact ContactId | APIEnableGroupMember GroupId GroupMemberId - | SetShowMessages ChatName Bool + | SetShowMessages ChatName MsgFilter | SetSendReceipts ChatName (Maybe Bool) + | SetShowMemberMessages GroupName ContactName Bool | ContactInfo ContactName | ShowGroupInfo GroupName | GroupMemberInfo GroupName ContactName diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 3831fad03e..22506218aa 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -150,6 +150,19 @@ instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +isReference :: ChatItem c d -> Bool +isReference ChatItem {chatDir, quotedItem} = case chatDir of + CIDirectRcv -> userItem quotedItem + CIGroupRcv _ -> userItem quotedItem + _ -> False + where + userItem = \case + Nothing -> False + Just CIQuote {chatDir = cd} -> case cd of + CIQDirectSnd -> True + CIQGroupSnd -> True + _ -> False + data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd CIDirectRcv :: CIDirection 'CTDirect 'MDRcv @@ -220,26 +233,6 @@ ciReactionAllowed :: ChatItem c d -> Bool ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content -data CIDeletedState = CIDeletedState - { markedDeleted :: Bool, - deletedByMember :: Maybe GroupMember - } - deriving (Show, Eq) - -chatItemDeletedState :: ChatItem c d -> Maybe CIDeletedState -chatItemDeletedState ChatItem {meta = CIMeta {itemDeleted}, content} = - ciDeletedToDeletedState <$> itemDeleted - where - ciDeletedToDeletedState cid = - case content of - CISndModerated -> CIDeletedState {markedDeleted = False, deletedByMember = byMember cid} - CIRcvModerated -> CIDeletedState {markedDeleted = False, deletedByMember = byMember cid} - _ -> CIDeletedState {markedDeleted = True, deletedByMember = byMember cid} - byMember :: CIDeleted c -> Maybe GroupMember - byMember = \case - CIModerated _ m -> Just m - CIDeleted _ -> Nothing - data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv @@ -929,6 +922,7 @@ checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of data CIDeleted (c :: ChatType) where CIDeleted :: Maybe UTCTime -> CIDeleted c + CIBlocked :: Maybe UTCTime -> CIDeleted c CIModerated :: Maybe UTCTime -> GroupMember -> CIDeleted 'CTGroup deriving instance Show (CIDeleted c) @@ -939,6 +933,7 @@ instance ToJSON (CIDeleted d) where data JSONCIDeleted = JCIDDeleted {deletedTs :: Maybe UTCTime} + | JCIBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} deriving (Show, Generic) @@ -949,11 +944,13 @@ instance ToJSON JSONCIDeleted where jsonCIDeleted :: CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case CIDeleted ts -> JCIDDeleted ts + CIBlocked ts -> JCIBlocked ts CIModerated ts m -> JCIDModerated ts m itemDeletedTs :: CIDeleted d -> Maybe UTCTime itemDeletedTs = \case CIDeleted ts -> ts + CIBlocked ts -> ts CIModerated ts _ -> ts data ChatItemInfo = ChatItemInfo diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 9abc8e4644..d3cdbcf3e4 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -19,12 +19,8 @@ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Type.Equality -import Data.Typeable (Typeable) import Data.Word (Word32) -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (Field, FromField (..), returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok +import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Protocol @@ -50,14 +46,6 @@ instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgD instance ToField MsgDirection where toField = toField . msgDirectionInt -fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a -fromIntField_ fromInt = \case - f@(Field (SQLInteger i) _) -> - case fromInt i of - Just x -> Ok x - _ -> returnError ConversionFailed f ("invalid integer: " <> show i) - f -> returnError ConversionFailed f "expecting SQLInteger column type" - data SMsgDirection (d :: MsgDirection) where SMDRcv :: SMsgDirection 'MDRcv SMDSnd :: SMsgDirection 'MDSnd diff --git a/src/Simplex/Chat/Migrations/M20231010_member_settings.hs b/src/Simplex/Chat/Migrations/M20231010_member_settings.hs new file mode 100644 index 0000000000..e31203e572 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231010_member_settings.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231010_member_settings where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231010_member_settings :: Query +m20231010_member_settings = + [sql| +ALTER TABLE group_members ADD COLUMN show_messages INTEGER NOT NULL DEFAULT 1; +|] + +down_m20231010_member_settings :: Query +down_m20231010_member_settings = + [sql| +ALTER TABLE group_members DROP COLUMN show_messages; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 542acbbebd..7308ef89ff 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -145,6 +145,7 @@ CREATE TABLE group_members( created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL), member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, + show_messages INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index bbdddf8ce0..0f69efe7c0 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -378,6 +378,11 @@ mcExtMsgContent = \case MCQuote _ c -> c MCForward c -> c +isQuote :: MsgContainer -> Bool +isQuote = \case + MCQuote {} -> True + _ -> False + data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} deriving (Eq, Show, Generic) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index c9e846a81f..3ef77cbb65 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -79,10 +79,10 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" @@ -97,11 +97,11 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 20fb8c7217..30e45a82dc 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -92,6 +92,7 @@ module Simplex.Chat.Store.Groups associateContactWithMemberRecord, deleteOldProbes, updateGroupSettings, + updateGroupMemberSettings, getXGrpMemIntroContDirect, getXGrpMemIntroContGroup, getHostConnId, @@ -131,30 +132,31 @@ import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs) :. userMemberRow) = let membership = toGroupMember userContactId userMemberRow - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} + memberSettings = GroupMemberSettings {showMessages} invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -250,11 +252,11 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -300,7 +302,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs - let chatSettings = ChatSettings {enableNtfs = True, sendRcpts = Nothing, favorite = False} + let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -345,7 +347,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo insertedRowId db GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs - let chatSettings = ChatSettings {enableNtfs = True, sendRcpts = Nothing, favorite = False} + let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs}, groupMemberId) getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId @@ -369,6 +371,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me memberRole, memberCategory, memberStatus, + memberSettings = defaultMemberSettings, invitedBy, localDisplayName, memberProfile, @@ -493,7 +496,7 @@ getUserGroupDetails db User {userId, userContactId} _contactId_ search_ = db [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, - mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, + mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp USING (group_profile_id) @@ -558,7 +561,7 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -665,6 +668,7 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con memberRole, memberCategory = GCInviteeMember, memberStatus = GSMemInvited, + memberSettings = defaultMemberSettings, invitedBy = IBUser, localDisplayName, memberProfile = profile, @@ -815,7 +819,8 @@ createNewMember_ |] (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) groupMemberId <- insertedRowId db - pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} + let memberSettings = defaultMemberSettings + pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, memberSettings, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} = @@ -1013,11 +1018,11 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -1106,7 +1111,7 @@ getGroupInfo db User {userId, userContactId} groupId = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -1502,6 +1507,18 @@ updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs, sendRcpts, favorite} = DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, sendRcpts, favorite, userId, groupId) +updateGroupMemberSettings :: DB.Connection -> User -> GroupId -> GroupMemberId -> GroupMemberSettings -> IO () +updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {showMessages} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET show_messages = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + |] + (showMessages, currentTs, userId, gId, gMemberId) + getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do fmap join . maybeFirstRow toCont $ diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 9ad0e8edc8..0f9abaa465 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -50,6 +51,7 @@ module Simplex.Chat.Store.Messages deleteGroupChatItem, updateGroupChatItemModerated, markGroupChatItemDeleted, + markGroupChatItemBlocked, updateDirectChatItemsRead, getDirectUnreadTimedItems, setDirectChatItemDeleteAt, @@ -438,7 +440,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -548,7 +550,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat, @@ -558,17 +560,17 @@ getGroupChatPreviews_ db User {userId, userContactId} = do f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -962,9 +964,9 @@ type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) -type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Bool, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow +type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe AMsgDirection, Maybe Text, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId) :. (Maybe Bool, Maybe UTCTime, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe AMsgDirection, Maybe Text, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId) :. (Maybe Int, Maybe UTCTime, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) @@ -1007,7 +1009,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = - let itemDeleted' = if itemDeleted then Just (CIDeleted @'CTDirect deletedTs) else Nothing + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = fromMaybe False itemEdited in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed @@ -1063,10 +1067,10 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = - let itemDeleted' = - if itemDeleted - then Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) - else Nothing + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) + _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed @@ -1225,8 +1229,8 @@ createChatItemVersion db itemId itemVersionTs msgContent = |] (itemId, toMCText msgContent, itemVersionTs) -deleteDirectChatItem :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> IO () -deleteDirectChatItem db User {userId} Contact {contactId} (CChatItem _ ci) = do +deleteDirectChatItem :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> IO () +deleteDirectChatItem db User {userId} Contact {contactId} ci = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId @@ -1257,8 +1261,8 @@ deleteChatItemVersions_ :: DB.Connection -> ChatItemId -> IO () deleteChatItemVersions_ db itemId = DB.execute db "DELETE FROM chat_item_versions WHERE chat_item_id = ?" (Only itemId) -markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> MessageId -> UTCTime -> IO () -markDirectChatItemDeleted db User {userId} Contact {contactId} (CChatItem _ ci) msgId deletedTs = do +markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> MessageId -> UTCTime -> IO (ChatItem 'CTDirect d) +markDirectChatItemDeleted db User {userId} Contact {contactId} ci@ChatItem {meta} msgId deletedTs = do currentTs <- liftIO getCurrentTime let itemId = chatItemId' ci insertChatItemMessage_ db itemId msgId currentTs @@ -1266,10 +1270,11 @@ markDirectChatItemDeleted db User {userId} Contact {contactId} (CChatItem _ ci) db [sql| UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, updated_at = ? + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] - (deletedTs, currentTs, userId, contactId, itemId) + (DBCIDeleted, deletedTs, currentTs, userId, contactId, itemId) + pure ci {meta = meta {itemDeleted = Just $ CIDeleted $ Just deletedTs}} getDirectChatItemBySharedMsgId :: DB.Connection -> User -> ContactId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItemBySharedMsgId db user@User {userId} contactId sharedMsgId = do @@ -1380,8 +1385,8 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt -deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO () -deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do +deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () +deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId @@ -1394,10 +1399,10 @@ deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do |] (userId, groupId, itemId) -updateGroupChatItemModerated :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> GroupMember -> UTCTime -> IO AChatItem -updateGroupChatItemModerated db User {userId} gInfo@GroupInfo {groupId} (CChatItem msgDir ci) m@GroupMember {groupMemberId} deletedTs = do +updateGroupChatItemModerated :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> GroupMember -> UTCTime -> IO (ChatItem 'CTGroup d) +updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMember {groupMemberId} deletedTs = do currentTs <- getCurrentTime - let toContent = msgDirToModeratedContent_ msgDir + let toContent = msgDirToModeratedContent_ $ msgDirection @d toText = ciModeratedText itemId = chatItemId' ci deleteChatItemMessages_ db itemId @@ -1411,24 +1416,47 @@ updateGroupChatItemModerated db User {userId} gInfo@GroupInfo {groupId} (CChatIt WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) (ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just currentTs) m), editable = False}, formattedText = Nothing}) + pure $ ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just currentTs) m), editable = False}, formattedText = Nothing} -markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> UTCTime -> IO () -markGroupChatItemDeleted db User {userId} GroupInfo {groupId} (CChatItem _ ci) msgId byGroupMember_ deletedTs = do +pattern DBCINotDeleted :: Int +pattern DBCINotDeleted = 0 + +pattern DBCIDeleted :: Int +pattern DBCIDeleted = 1 + +pattern DBCIBlocked :: Int +pattern DBCIBlocked = 2 + +markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Maybe GroupMember -> UTCTime -> IO (ChatItem 'CTGroup d) +markGroupChatItemDeleted db User {userId} GroupInfo {groupId} ci@ChatItem {meta} msgId byGroupMember_ deletedTs = do currentTs <- liftIO getCurrentTime let itemId = chatItemId' ci - deletedByGroupMemberId = case byGroupMember_ of - Just GroupMember {groupMemberId} -> Just groupMemberId - _ -> Nothing + (deletedByGroupMemberId, itemDeleted) = case byGroupMember_ of + Just m@GroupMember {groupMemberId} -> (Just groupMemberId, Just $ CIModerated (Just deletedTs) m) + _ -> (Nothing, Just $ CIDeleted @'CTGroup (Just deletedTs)) insertChatItemMessage_ db itemId msgId currentTs DB.execute db [sql| UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] - (deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) + (DBCIDeleted, deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) + pure ci {meta = meta {itemDeleted}} + +markGroupChatItemBlocked :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv) +markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do + deletedTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + |] + (DBCIBlocked, deletedTs, deletedTs, userId, groupId, chatItemId' ci) + pure ci {meta = meta {itemDeleted = Just $ CIBlocked $ Just deletedTs}} getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do @@ -1486,17 +1514,17 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 5c44b8cded..60783f3664 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -84,6 +84,7 @@ import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash +import Simplex.Chat.Migrations.M20231010_member_settings import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -167,7 +168,8 @@ schemaMigrations = ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), - ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash) + ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash), + ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 3ff765b75d..2a90b54d7f 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -241,20 +241,20 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] [":user_id" := userId, ":profile_id" := profileId] -type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) toContact :: User -> ContactRow :. ConnectionRow -> Contact toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toConnection connRow - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} in case toMaybeConnection connRow of Just activeConn -> let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 556e4f792e..a45390e8c3 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -19,7 +19,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) -import Simplex.Chat (processChatCommand, chatNtf, contactNtf, groupNtf, userNtf) +import Simplex.Chat (processChatCommand) import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages @@ -28,7 +28,7 @@ import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..), msgContentText) import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) -import Simplex.Chat.Types (Contact, GroupInfo (..), User (..), UserContactRequest (..)) +import Simplex.Chat.Types import Simplex.Chat.View import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Encoding.String @@ -140,8 +140,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d forever $ do (_, r) <- atomically $ readTBQueue outputQ case r of - CRNewChatItem _ ci -> markChatItemRead ci - CRChatItemUpdated _ ci -> markChatItemRead ci + CRNewChatItem u ci -> markChatItemRead u ci + CRChatItemUpdated u ci -> markChatItemRead u ci _ -> pure () let printResp = case logFilePath of Just path -> if logResponseToFile r then logResponse path else printToTerminal ct @@ -150,10 +150,10 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseString cc liveItems r >>= printResp responseNotification ct cc r where - markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = - case (muted chat chatDir, itemStatus) of - (False, CISRcvNew) -> do - let itemId = chatItemId' item + markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = + case (chatDirNtf u chat chatDir (isReference ci), itemStatus) of + (True, CISRcvNew) -> do + let itemId = chatItemId' ci chatRef = chatInfoToRef chat void $ runReaderT (runExceptT $ processChatCommand (APIChatRead chatRef (Just (itemId, itemId)))) cc _ -> pure () @@ -161,8 +161,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () responseNotification t@ChatTerminal {sendNotification} cc = \case - CRNewChatItem u (AChatItem _ SMDRcv cInfo ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> - when (chatNtf u cInfo) $ do + CRNewChatItem u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> + when (chatDirNtf u cInfo chatDir $ isReference ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) @@ -170,26 +170,26 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case _ -> pure () where text = msgText mc formattedText - CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ChatItem {content = CIRcvMsgContent _}) -> - whenCurrUser cc u $ when (chatNtf u cInfo) $ setActiveChat t cInfo - CRContactConnected u ct _ -> when (contactNtf u ct) $ do + CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isReference ci) $ setActiveChat t cInfo + CRContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") CRContactAnotherClient u ct -> do whenCurrUser cc u $ unsetActiveContact t ct - when (contactNtf u ct) $ sendNtf (viewContactName ct <> "> ", "connected to another client") + when (contactNtf u ct False) $ sendNtf (viewContactName ct <> "> ", "connected to another client") CRContactsDisconnected srv _ -> serverNtf srv "disconnected" CRContactsSubscribed srv _ -> serverNtf srv "connected" CRReceivedGroupInvitation u g ct _ _ -> - when (contactNtf u ct) $ + when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") - CRUserJoinedGroup u g _ -> when (groupNtf u g) $ do + CRUserJoinedGroup u g _ -> when (groupNtf u g False) $ do whenCurrUser cc u $ setActiveGroup t g sendNtf ("#" <> viewGroupName g, "you are connected to group") CRJoinedGroupMember u g m -> - when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") CRConnectedToGroupMember u g m _ -> - when (groupNtf u g) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") _ -> pure () diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index de56baad92..83d0664a04 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -37,7 +37,11 @@ import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime) -import Database.SQLite.Simple.FromField (FromField (..)) +import Data.Typeable (Typeable) +import Database.SQLite.Simple (ResultError (..), SQLData (..)) +import Database.SQLite.Simple.FromField (returnError, FromField(..)) +import Database.SQLite.Simple.Internal (Field (..)) +import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Types.Preferences @@ -46,7 +50,7 @@ import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version @@ -385,7 +389,7 @@ contactAndGroupIds = \case -- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) data ChatSettings = ChatSettings - { enableNtfs :: Bool, + { enableNtfs :: MsgFilter, sendRcpts :: Maybe Bool, favorite :: Bool } @@ -396,13 +400,48 @@ instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOpt defaultChatSettings :: ChatSettings defaultChatSettings = ChatSettings - { enableNtfs = True, + { enableNtfs = MFAll, sendRcpts = Nothing, favorite = False } -pattern DisableNtfs :: ChatSettings -pattern DisableNtfs <- ChatSettings {enableNtfs = False} +chatHasNtfs :: ChatSettings -> Bool +chatHasNtfs ChatSettings {enableNtfs} = enableNtfs /= MFNone + +data MsgFilter = MFNone | MFAll | MFMentions + deriving (Eq, Show, Generic) + +instance FromJSON MsgFilter where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MF" + +instance ToJSON MsgFilter where + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MF" + toJSON = J.genericToJSON . enumJSON $ dropPrefix "MF" + +instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP + +instance ToField MsgFilter where toField = toField . msgFilterInt + +msgFilterInt :: MsgFilter -> Int +msgFilterInt = \case + MFNone -> 0 + MFAll -> 1 + MFMentions -> 2 + +msgFilterIntP :: Int64 -> Maybe MsgFilter +msgFilterIntP = \case + 0 -> Just MFNone + 1 -> Just MFAll + 2 -> Just MFMentions + _ -> Just MFAll + +fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a +fromIntField_ fromInt = \case + f@(Field (SQLInteger i) _) -> + case fromInt i of + Just x -> Ok x + _ -> returnError ConversionFailed f ("invalid integer: " <> show i) + f -> returnError ConversionFailed f "expecting SQLInteger column type" featureAllowed :: SChatFeature f -> (PrefEnabled -> Bool) -> Contact -> Bool featureAllowed feature forWhom Contact {mergedPreferences} = @@ -630,6 +669,7 @@ data GroupMember = GroupMember memberRole :: GroupMemberRole, memberCategory :: GroupMemberCategory, memberStatus :: GroupMemberStatus, + memberSettings :: GroupMemberSettings, invitedBy :: InvitedBy, localDisplayName :: ContactName, -- for membership, memberProfile can be either user's profile or incognito profile, based on memberIncognito test. @@ -764,6 +804,16 @@ instance ToJSON GroupMemberRole where toJSON = strToJSON toEncoding = strToJEncoding +data GroupMemberSettings = GroupMemberSettings + { showMessages :: Bool + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupMemberSettings where toEncoding = J.genericToEncoding J.defaultOptions + +defaultMemberSettings :: GroupMemberSettings +defaultMemberSettings = GroupMemberSettings {showMessages = True} + newtype Probe = Probe {unProbe :: ByteString} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f60b7cd82f..f465375f12 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -102,15 +102,15 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView - CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts tz <> viewItemReactions item + CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts - CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts tz + CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci - CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView - CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction showReactions chat reaction added ts tz + CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView + CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc s f t -> ttyUser u $ viewSentBroadcast mc s f ts tz t CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr @@ -349,24 +349,56 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs - unmuted :: ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted chat ChatItem {chatDir} = unmuted' chat chatDir - unmutedReaction :: ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] - unmutedReaction chat CIReaction {chatDir} = unmuted' chat chatDir - unmuted' :: ChatInfo c -> CIDirection c d -> [StyledString] -> [StyledString] - unmuted' chat chatDir s - | muted chat chatDir = [] - | otherwise = s + unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isReference ci + unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] + unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False + unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] + unmuted' u chat chatDir reference s + | chatDirNtf u chat chatDir reference = s + | otherwise = [] + +userNtf :: User -> Bool +userNtf User {showNtfs, activeUser} = showNtfs || activeUser + +chatNtf :: User -> ChatInfo c -> Bool -> Bool +chatNtf user cInfo reference = case cInfo of + DirectChat ct -> contactNtf user ct reference + GroupChat g -> groupNtf user g reference + _ -> False + +chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool +chatDirNtf user cInfo chatDir reference = case (cInfo, chatDir) of + (DirectChat ct, CIDirectRcv) -> contactNtf user ct reference + (GroupChat g, CIGroupRcv m) -> groupNtf user g reference && showMessages (memberSettings m) + _ -> True + +contactNtf :: User -> Contact -> Bool -> Bool +contactNtf user Contact {chatSettings} reference = + userNtf user && showMessageNtf chatSettings reference + +groupNtf :: User -> GroupInfo -> Bool -> Bool +groupNtf user GroupInfo {chatSettings} reference = + userNtf user && showMessageNtf chatSettings reference + +showMessageNtf :: ChatSettings -> Bool -> Bool +showMessageNtf ChatSettings {enableNtfs} reference = + enableNtfs == MFAll || (reference && enableNtfs == MFMentions) chatItemDeletedText :: ChatItem c d -> Maybe GroupMember -> Maybe Text -chatItemDeletedText ci membership_ = deletedStateToText <$> chatItemDeletedState ci +chatItemDeletedText ChatItem {meta = CIMeta {itemDeleted}, content} membership_ = + deletedText <$> itemDeleted where - deletedStateToText = \CIDeletedState {markedDeleted, deletedByMember} -> - if markedDeleted - then "marked deleted" <> byMember deletedByMember - else "deleted" <> byMember deletedByMember - byMember m_ = case (m_, membership_) of - (Just GroupMember {groupMemberId = mId, localDisplayName = n}, Just GroupMember {groupMemberId = membershipId}) -> + deletedText = \case + CIModerated _ m -> markedDeleted content <> byMember m + CIDeleted _ -> markedDeleted content + CIBlocked _ -> "blocked" + markedDeleted = \case + CISndModerated -> "deleted" + CIRcvModerated -> "deleted" + _ -> "marked deleted" + byMember GroupMember {groupMemberId = mId, localDisplayName = n} = case membership_ of + Just GroupMember {groupMemberId = membershipId} -> " by " <> if mId == membershipId then "you" else n _ -> "" @@ -385,12 +417,6 @@ viewUsersList = mapMaybe userInfo . sortOn ldn <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -muted :: ChatInfo c -> CIDirection c d -> Bool -muted chat chatDir = case (chat, chatDir) of - (DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True - (GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _) -> True - _ -> False - viewGroupSubscribed :: GroupInfo -> [StyledString] viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] @@ -692,7 +718,7 @@ viewContactsList = in map (\ct -> ctIncognito ct <> ttyFullContact ct <> muted' ct <> alias ct) . sortOn ldn where muted' Contact {chatSettings, localDisplayName = ldn} - | enableNtfs chatSettings = "" + | chatHasNtfs chatSettings = "" | otherwise = " (muted, you can " <> highlight ("/unmute @" <> ldn) <> ")" alias Contact {profile = LocalProfile {localAlias}} | localAlias == "" = "" @@ -825,22 +851,25 @@ viewGroupMembers :: Group -> [StyledString] viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members where removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft - groupMember m = memIncognito m <> ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m - role :: GroupMember -> StyledString - role m = plain . strEncode $ m.memberRole + groupMember m = memIncognito m <> ttyFullMember m <> ": " <> plain (intercalate ", " $ [role m] <> category m <> status m <> muted m) + role :: GroupMember -> String + role m = B.unpack . strEncode $ m.memberRole category m = case memberCategory m of - GCUserMember -> "you, " - GCInviteeMember -> "invited, " - GCHostMember -> "host, " - _ -> "" + GCUserMember -> ["you"] + GCInviteeMember -> ["invited"] + GCHostMember -> ["host"] + _ -> [] status m = case memberStatus m of - GSMemRemoved -> "removed" - GSMemLeft -> "left" - GSMemInvited -> "not yet joined" - GSMemConnected -> "connected" - GSMemComplete -> "connected" - GSMemCreator -> "created group" - _ -> "" + GSMemRemoved -> ["removed"] + GSMemLeft -> ["left"] + GSMemInvited -> ["not yet joined"] + GSMemConnected -> ["connected"] + GSMemComplete -> ["connected"] + GSMemCreator -> ["created group"] + _ -> [] + muted m + | showMessages (memberSettings m) = [] + | otherwise = ["blocked"] viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected ct userIncognitoProfile testView = @@ -863,7 +892,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs where ldn_ :: GroupInfo -> Text ldn_ g = T.toLower g.localDisplayName - groupSS (g@GroupInfo {membership, chatSettings}, GroupSummary {currentMembers}) = + groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s @@ -872,9 +901,13 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" - _ - | enableNtfs chatSettings -> " (" <> memberCount <> ")" - | otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" + _ -> " (" <> memberCount <> + case enableNtfs of + MFAll -> ")" + MFNone -> ", muted, " <> unmute + MFMentions -> ", mentions only, " <> unmute + where + unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 47333906bb..b4c3c53cd9 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -70,7 +70,7 @@ chatDirectTests = do it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact - it "mute/unmute group" testMuteGroup + it "mute/unmute group and member" testMuteGroup describe "multiple users" $ do it "create second user" testCreateSecondUser it "multiple users subscribe and receive messages after restart" testUsersSubscribeAfterRestart @@ -1196,14 +1196,79 @@ testMuteGroup = concurrently_ (bob hi") + bob #> "#team hello" + concurrently_ + (alice <# "#team bob> hello") + (cath <# "#team bob> hello") + cath `send` "> #team (hello) hello too!" + cath <# "#team > bob hello" + cath <## " hello too!" + concurrently_ + (bob > bob hello" + alice <## " hello too!" + ) + bob ##> "/unmute mentions #team" + bob <## "ok" + alice `send` "> #team @bob (hello) hey bob!" + alice <# "#team > bob hello" + alice <## " hey bob!" + concurrently_ + ( do bob <# "#team alice> > bob hello" + bob <## " hey bob!" + ) + ( do cath <# "#team alice> > bob hello" + cath <## " hey bob!" + ) + alice `send` "> #team @cath (hello) hey cath!" + alice <# "#team > cath hello too!" + alice <## " hey cath!" + concurrently_ + (bob > cath hello too!" + cath <## " hey cath!" + ) bob ##> "/gs" - bob <## "#team (3 members, muted, you can /unmute #team)" + bob <## "#team (3 members, mentions only, you can /unmute #team)" bob ##> "/unmute #team" bob <## "ok" alice #> "#team hi again" concurrently_ (bob <# "#team alice> hi again") (cath <# "#team alice> hi again") + bob ##> "/block #team alice" + bob <## "ok" + bob ##> "/ms team" + bob <## "bob (Bob): admin, you, connected" + bob <## "alice (Alice): owner, host, connected, blocked" + bob <## "cath (Catherine): admin, connected" + alice #> "#team test 1" + concurrently_ + (bob test 1") + cath #> "#team test 2" + concurrently_ + (bob <# "#team cath> test 2") + (alice <# "#team cath> test 2") + bob ##> "/tail #team 3" + bob <# "#team alice> hi again" + bob <# "#team alice> test 1 [blocked]" + bob <# "#team cath> test 2" + threadDelay 1000000 + bob ##> "/unblock #team alice" + bob <## "ok" + bob ##> "/ms team" + bob <## "bob (Bob): admin, you, connected" + bob <## "alice (Alice): owner, host, connected" + bob <## "cath (Catherine): admin, connected" + alice #> "#team test 3" + concurrently_ + (bob <# "#team alice> test 3") + (cath <# "#team alice> test 3") + cath #> "#team test 4" + concurrently_ + (bob <# "#team cath> test 4") + (alice <# "#team cath> test 4") bob ##> "/gs" bob <## "#team (3 members)" @@ -1937,7 +2002,7 @@ testUserPrivacy = -- shows hidden user when active alice ##> "/users" alice <## "alice (Alice)" - alice <## "alisa (active, hidden, muted)" + alice <## "alisa (active, hidden, muted, unread: 1)" -- hidden message is saved alice ##> "/tail" alice <##? chatHistory diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6578df84c0..55d02b9488 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1538,7 +1538,6 @@ testGroupDelayedModerationFullDelete tmp = do testGroupAsync :: HasCallStack => FilePath -> IO () testGroupAsync tmp = do - print (0 :: Integer) withNewTestChat tmp "alice" aliceProfile $ \alice -> do withNewTestChat tmp "bob" bobProfile $ \bob -> do connectUsers alice bob From 8ff6b392c2444b023cb8668069cc27709702ad6d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 21:15:31 +0100 Subject: [PATCH 19/80] core: rename "reference" to "mention" --- src/Simplex/Chat/Messages.hs | 4 ++-- src/Simplex/Chat/Terminal/Output.hs | 6 +++--- src/Simplex/Chat/View.hs | 30 ++++++++++++++--------------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 22506218aa..21da83ad05 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -150,8 +150,8 @@ instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -isReference :: ChatItem c d -> Bool -isReference ChatItem {chatDir, quotedItem} = case chatDir of +isMention :: ChatItem c d -> Bool +isMention ChatItem {chatDir, quotedItem} = case chatDir of CIDirectRcv -> userItem quotedItem CIGroupRcv _ -> userItem quotedItem _ -> False diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index a45390e8c3..a623536cd9 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -151,7 +151,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseNotification ct cc r where markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = - case (chatDirNtf u chat chatDir (isReference ci), itemStatus) of + case (chatDirNtf u chat chatDir (isMention ci), itemStatus) of (True, CISRcvNew) -> do let itemId = chatItemId' ci chatRef = chatInfoToRef chat @@ -162,7 +162,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () responseNotification t@ChatTerminal {sendNotification} cc = \case CRNewChatItem u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> - when (chatDirNtf u cInfo chatDir $ isReference ci) $ do + when (chatDirNtf u cInfo chatDir $ isMention ci) $ do whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) @@ -171,7 +171,7 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case where text = msgText mc formattedText CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> - whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isReference ci) $ setActiveChat t cInfo + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isMention ci) $ setActiveChat t cInfo CRContactConnected u ct _ -> when (contactNtf u ct False) $ do whenCurrUser cc u $ setActiveContact t ct sendNtf (viewContactName ct <> "> ", "connected") diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f465375f12..86a10988d6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -350,40 +350,40 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isReference ci + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isMention ci unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] - unmuted' u chat chatDir reference s - | chatDirNtf u chat chatDir reference = s + unmuted' u chat chatDir mention s + | chatDirNtf u chat chatDir mention = s | otherwise = [] userNtf :: User -> Bool userNtf User {showNtfs, activeUser} = showNtfs || activeUser chatNtf :: User -> ChatInfo c -> Bool -> Bool -chatNtf user cInfo reference = case cInfo of - DirectChat ct -> contactNtf user ct reference - GroupChat g -> groupNtf user g reference +chatNtf user cInfo mention = case cInfo of + DirectChat ct -> contactNtf user ct mention + GroupChat g -> groupNtf user g mention _ -> False chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool -chatDirNtf user cInfo chatDir reference = case (cInfo, chatDir) of - (DirectChat ct, CIDirectRcv) -> contactNtf user ct reference - (GroupChat g, CIGroupRcv m) -> groupNtf user g reference && showMessages (memberSettings m) +chatDirNtf user cInfo chatDir mention = case (cInfo, chatDir) of + (DirectChat ct, CIDirectRcv) -> contactNtf user ct mention + (GroupChat g, CIGroupRcv m) -> groupNtf user g mention && showMessages (memberSettings m) _ -> True contactNtf :: User -> Contact -> Bool -> Bool -contactNtf user Contact {chatSettings} reference = - userNtf user && showMessageNtf chatSettings reference +contactNtf user Contact {chatSettings} mention = + userNtf user && showMessageNtf chatSettings mention groupNtf :: User -> GroupInfo -> Bool -> Bool -groupNtf user GroupInfo {chatSettings} reference = - userNtf user && showMessageNtf chatSettings reference +groupNtf user GroupInfo {chatSettings} mention = + userNtf user && showMessageNtf chatSettings mention showMessageNtf :: ChatSettings -> Bool -> Bool -showMessageNtf ChatSettings {enableNtfs} reference = - enableNtfs == MFAll || (reference && enableNtfs == MFMentions) +showMessageNtf ChatSettings {enableNtfs} mention = + enableNtfs == MFAll || (mention && enableNtfs == MFMentions) chatItemDeletedText :: ChatItem c d -> Maybe GroupMember -> Maybe Text chatItemDeletedText ChatItem {meta = CIMeta {itemDeleted}, content} membership_ = From 4df8ea2e78fa89deec5857d407a15b5574450803 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 11 Oct 2023 23:07:05 +0100 Subject: [PATCH 20/80] ui: update types for notification and member settings (#3201) --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++---------- apps/ios/SimpleXChat/APITypes.swift | 12 ++++-- apps/ios/SimpleXChat/ChatTypes.swift | 12 ++++-- .../chat/simplex/common/model/ChatModel.kt | 17 +++++--- .../chat/simplex/common/model/SimpleXAPI.kt | 11 ++++- .../views/chatlist/ChatListNavLinkView.kt | 6 +-- 7 files changed, 62 insertions(+), 38 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 389080efc5..81473709cb 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -965,7 +965,7 @@ struct ChatView: View { func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults - chatSettings.enableNtfs = enableNtfs + chatSettings.enableNtfs = enableNtfs ? .all : .none updateChatSettings(chat, chatSettings: chatSettings) } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 18021e3079..35d0b1de8a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -85,6 +85,11 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; }; + 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0112AD746C8001FD661 /* libgmpxx.a */; }; + 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0122AD746C8001FD661 /* libffi.a */; }; + 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; + 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; + 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0152AD746C8001FD661 /* libgmp.a */; }; 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; }; 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; @@ -114,11 +119,6 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; - 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739922AD44E2E009470A9 /* libgmp.a */; }; - 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */; }; - 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739942AD44E2E009470A9 /* libffi.a */; }; - 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */; }; - 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739962AD44E2E009470A9 /* libgmpxx.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -358,6 +358,11 @@ 5CA85D0A297218AA0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 5CA85D0C297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = "it.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CA85D0D297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; + 5CA8D0112AD746C8001FD661 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CA8D0122AD746C8001FD661 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; + 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; + 5CA8D0152AD746C8001FD661 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5CAB912529E93F9400F34A95 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 5CAC41182A192D8400C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 5CAC411A2A192DE800C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = "ja.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -395,11 +400,6 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; - 5CC739922AD44E2E009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a"; sourceTree = ""; }; - 5CC739942AD44E2E009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a"; sourceTree = ""; }; - 5CC739962AD44E2E009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CC739982AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a in Frameworks */, + 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */, + 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */, + 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, + 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CC739972AD44E2E009470A9 /* libgmp.a in Frameworks */, - 5CC7399A2AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a in Frameworks */, - 5CC739992AD44E2E009470A9 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CC7399B2AD44E2E009470A9 /* libgmpxx.a in Frameworks */, + 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CC739942AD44E2E009470A9 /* libffi.a */, - 5CC739922AD44E2E009470A9 /* libgmp.a */, - 5CC739962AD44E2E009470A9 /* libgmpxx.a */, - 5CC739932AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F-ghc8.10.7.a */, - 5CC739952AD44E2E009470A9 /* libHSsimplex-chat-5.4.0.0-JjDpmMNHLrsHjXbdowMF4F.a */, + 5CA8D0122AD746C8001FD661 /* libffi.a */, + 5CA8D0152AD746C8001FD661 /* libgmp.a */, + 5CA8D0112AD746C8001FD661 /* libgmpxx.a */, + 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, + 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 951f726be9..d65b9b3283 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1182,17 +1182,23 @@ public struct KeepAliveOpts: Codable, Equatable { } public struct ChatSettings: Codable { - public var enableNtfs: Bool + public var enableNtfs: MsgFilter public var sendRcpts: Bool? public var favorite: Bool - public init(enableNtfs: Bool, sendRcpts: Bool?, favorite: Bool) { + public init(enableNtfs: MsgFilter, sendRcpts: Bool?, favorite: Bool) { self.enableNtfs = enableNtfs self.sendRcpts = sendRcpts self.favorite = favorite } - public static let defaults: ChatSettings = ChatSettings(enableNtfs: true, sendRcpts: nil, favorite: false) + public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false) +} + +public enum MsgFilter: String, Codable { + case none + case all + case mentions } public struct UserMsgReceiptSettings: Codable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f9996d8400..37e4f0316a 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1292,7 +1292,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } public var ntfsEnabled: Bool { - self.chatSettings?.enableNtfs ?? false + self.chatSettings?.enableNtfs == .all } public var chatSettings: ChatSettings? { @@ -1758,6 +1758,7 @@ public struct GroupMember: Identifiable, Decodable { public var memberRole: GroupMemberRole public var memberCategory: GroupMemberCategory public var memberStatus: GroupMemberStatus + public var memberSettings: GroupMemberSettings public var invitedBy: InvitedBy public var localDisplayName: ContactName public var memberProfile: LocalProfile @@ -1851,6 +1852,7 @@ public struct GroupMember: Identifiable, Decodable { memberRole: .admin, memberCategory: .inviteeMember, memberStatus: .memComplete, + memberSettings: GroupMemberSettings(showMessages: true), invitedBy: .user, localDisplayName: "alice", memberProfile: LocalProfile.sampleData, @@ -1860,6 +1862,10 @@ public struct GroupMember: Identifiable, Decodable { ) } +public struct GroupMemberSettings: Decodable { + var showMessages: Bool +} + public struct GroupMemberRef: Decodable { var groupMemberId: Int64 var profile: Profile @@ -1983,8 +1989,8 @@ public enum ConnectionEntity: Decodable { public var ntfsEnabled: Bool { switch self { - case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs ?? false - case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs + case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs == .all + case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all case .sndFileConnection: return false case .rcvFileConnection: return false case let .userContactConnection(userContact): return userContact.groupId == nil diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 8687ac390a..88ad78612c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -12,7 +12,6 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -726,7 +725,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready override val sendMsgEnabled get() = contactConnection.sendMsgEnabled - override val ntfsEnabled get() = contactConnection.incognito + override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL @@ -822,7 +821,7 @@ data class Contact( (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val ntfsEnabled get() = chatSettings.enableNtfs + override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser @@ -869,7 +868,7 @@ data class Contact( activeConn = Connection.sampleData, contactUsed = true, contactStatus = ContactStatus.Active, - chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), + chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), userPreferences = ChatPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData, createdAt = Clock.System.now(), @@ -1009,7 +1008,7 @@ data class GroupInfo ( override val apiId get() = groupId override val ready get() = membership.memberActive override val sendMsgEnabled get() = membership.memberActive - override val ntfsEnabled get() = chatSettings.enableNtfs + override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -1041,7 +1040,7 @@ data class GroupInfo ( fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, hostConnCustomUserProfileId = null, - chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), + chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -1073,6 +1072,7 @@ data class GroupMember ( var memberRole: GroupMemberRole, var memberCategory: GroupMemberCategory, var memberStatus: GroupMemberStatus, + var memberSettings: GroupMemberSettings, var invitedBy: InvitedBy, val localDisplayName: String, val memberProfile: LocalProfile, @@ -1140,6 +1140,7 @@ data class GroupMember ( memberRole = GroupMemberRole.Member, memberCategory = GroupMemberCategory.InviteeMember, memberStatus = GroupMemberStatus.MemComplete, + memberSettings = GroupMemberSettings(showMessages = true), invitedBy = InvitedBy.IBUser(), localDisplayName = "alice", memberProfile = LocalProfile.sampleData, @@ -1150,6 +1151,9 @@ data class GroupMember ( } } +@Serializable +data class GroupMemberSettings(val showMessages: Boolean) {} + @Serializable class GroupMemberRef( val groupMemberId: Long, @@ -1844,6 +1848,7 @@ enum class SndCIStatusProgress { @Serializable sealed class CIDeleted { @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted() + @Serializable @SerialName("blocked") class Blocked(val deletedTs: Instant?): CIDeleted() @Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 43043d65fe..3644268ba3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2472,15 +2472,22 @@ data class KeepAliveOpts( @Serializable data class ChatSettings( - val enableNtfs: Boolean, + val enableNtfs: MsgFilter, val sendRcpts: Boolean?, val favorite: Boolean ) { companion object { - val defaults: ChatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false) + val defaults: ChatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false) } } +@Serializable +enum class MsgFilter { + @SerialName("all") All, + @SerialName("none") None, + @SerialName("mentions") Mentions, +} + @Serializable data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 41c94f21d9..566c981811 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -595,8 +595,8 @@ fun groupInvitationAcceptedAlert() { ) } -fun toggleNotifications(chat: Chat, enableNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { - val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = enableNtfs) +fun toggleNotifications(chat: Chat, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { + val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None) updateChatSettings(chat, chatSettings, chatModel, currentState) } @@ -627,7 +627,7 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo } if (res && newChatInfo != null) { chatModel.updateChatInfo(newChatInfo) - if (!chatSettings.enableNtfs) { + if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chat.id) } val current = currentState?.value From 7b488c7f1ba661d6198b68c88926cd586e8b8d42 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 12 Oct 2023 11:52:14 +0400 Subject: [PATCH 21/80] tests: improve tests (#3203) --- tests/ChatTests/Groups.hs | 2 +- tests/ChatTests/Profiles.hs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 55d02b9488..77b70c3e78 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2461,7 +2461,7 @@ testPlanGroupLinkLeaveRejoin = concurrentlyN_ [ alice <### [ "bob_1 (Bob): contact is connected", - "bob_1 invited to group #team via your group link", + EndsWith "invited to group #team via your group link", EndsWith "joined the group", "contact bob_1 is merged into bob", "use @bob to send messages" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 0d7683c4d7..80e1709222 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -650,8 +650,10 @@ testPlanAddressConnecting tmp = do alice ##> "/ad" getContactLink alice True withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 bob ##> ("/c " <> cLink) bob <## "connection request sent!" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" alice <## "bob (Bob) wants to connect to you!" From 247f2c9e618eba9fa5eef8277efe8df666467e49 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:26:44 +0400 Subject: [PATCH 22/80] tests: modify testMemberContactInvitedConnectionReplaced to not rely on chat item order, print output (#3198) --- tests/ChatClient.hs | 2 ++ tests/ChatTests/Groups.hs | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 7da5263253..7b3fb44998 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -210,6 +210,8 @@ withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +-- enable output for specific chat controller, use like this: +-- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a withTestOutput cc runTest = runTest cc {printOutput = True} diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bf740a960f..82a5c1bd67 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2886,9 +2886,9 @@ testMemberContactProhibitedRepeatInv = testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \c -> withTestOutput c $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -2910,7 +2910,9 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob ##> "/_get chat @2 count=100" + items <- chat <$> getTermLine bob + items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From b956988a83a89e357b63cfb6391132b7b84b988d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:20:31 +0400 Subject: [PATCH 23/80] tests: simplify testMemberContactInvitedConnectionReplaced (for stable) --- tests/ChatTests/Groups.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 77b70c3e78..4c31805265 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3278,7 +3278,7 @@ testMemberContactInvitedConnectionReplaced tmp = do bob ##> "/_get chat @2 count=100" items <- chat <$> getTermLine bob - items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] + items `shouldContain` [(0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From 5d078bec536eb0149c1205cb80d6bdbb21e25497 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:20:31 +0400 Subject: [PATCH 24/80] tests: simplify testMemberContactInvitedConnectionReplaced (for stable) --- tests/ChatTests/Groups.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 82a5c1bd67..1d2ff7ad97 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2912,7 +2912,7 @@ testMemberContactInvitedConnectionReplaced tmp = do bob ##> "/_get chat @2 count=100" items <- chat <$> getTermLine bob - items `shouldContain` [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] + items `shouldContain` [(0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 From 8ffe1c23c176b7d857b3ef534be26e2b5a67b41d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 12 Oct 2023 20:19:57 +0800 Subject: [PATCH 25/80] android, desktop: better handling of parallel updates of chats (#3204) * android, desktop: better handling of parallel updates of chats * one more case --- .../main/java/chat/simplex/app/SimplexApp.kt | 32 +++++++++-------- .../chat/simplex/common/model/ChatModel.kt | 34 +++++++++++-------- .../chat/simplex/common/model/SimpleXAPI.kt | 14 +++++--- .../common/views/database/DatabaseView.kt | 8 +++-- 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index f70032788b..d2c4465172 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -9,12 +9,14 @@ import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.* import chat.simplex.common.views.call.RcvCallInvitation import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withLock import java.io.* import java.util.* import java.util.concurrent.TimeUnit @@ -52,21 +54,23 @@ class SimplexApp: Application(), LifecycleEventObserver { Lifecycle.Event.ON_START -> { isAppOnForeground = true if (chatModel.chatRunning.value == true) { - kotlin.runCatching { - val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats()) - /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ - if (chatModel.currentUser.value?.userId == currentUserId) { - val currentChatId = chatModel.chatId.value - val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null - if (oldStats != null) { - val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId } - /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ - if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) + updatingChatsMutex.withLock { + kotlin.runCatching { + val currentUserId = chatModel.currentUser.value?.userId + val chats = ArrayList(chatController.apiGetChats()) + /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ + if (chatModel.currentUser.value?.userId == currentUserId) { + val currentChatId = chatModel.chatId.value + val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null + if (oldStats != null) { + val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId } + /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ + if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) + } + chatModel.updateChats(chats) } - chatModel.updateChats(chats) - } - }.onFailure { Log.e(TAG, it.stackTraceToString()) } + }.onFailure { Log.e(TAG, it.stackTraceToString()) } + } } } Lifecycle.Event.ON_RESUME -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 88ad78612c..ac5f9fb8a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -16,6 +15,8 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.* import kotlinx.datetime.TimeZone import kotlinx.serialization.* @@ -102,6 +103,8 @@ object ChatModel { val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } + var updatingChatsMutex: Mutex = Mutex() + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -198,7 +201,7 @@ object ChatModel { } } - suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) { + suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { // update previews val i = getChatIndex(cInfo.id) val chat: Chat @@ -221,10 +224,11 @@ object ChatModel { } else { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) } - // add to current chat - if (chatId.value == cInfo.id) { - Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") - withContext(Dispatchers.Main) { + Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") + withContext(Dispatchers.Main) { + // add to current chat + if (chatId.value == cInfo.id) { + Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}") // Prevent situation when chat item already in the list received from backend if (chatItems.none { it.id == cItem.id }) { if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { @@ -238,7 +242,7 @@ object ChatModel { } } - suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean { + suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { // update previews val i = getChatIndex(cInfo.id) val chat: Chat @@ -258,10 +262,10 @@ object ChatModel { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) res = true } - // update current chat - return if (chatId.value == cInfo.id) { - Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") - withContext(Dispatchers.Main) { + Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") + return withContext(Dispatchers.Main) { + // update current chat + if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem @@ -272,15 +276,15 @@ object ChatModel { Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } + } else { + res } - } else { - res } } suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) { - if (chatId.value == cInfo.id) { - withContext(Dispatchers.Main) { + withContext(Dispatchers.Main) { + if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 3644268ba3..dc2c3c0f25 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -4,6 +4,7 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -16,6 +17,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* @@ -349,8 +351,10 @@ object ChatController { startReceiver() Log.d(TAG, "startChat: started") } else { - val chats = apiGetChats() - chatModel.updateChats(chats) + updatingChatsMutex.withLock { + val chats = apiGetChats() + chatModel.updateChats(chats) + } Log.d(TAG, "startChat: running") } } catch (e: Error) { @@ -384,8 +388,10 @@ object ChatController { suspend fun getUserChatData() { chatModel.userAddress.value = apiGetUserAddress() chatModel.chatItemTTL.value = getChatItemTTL() - val chats = apiGetChats() - chatModel.updateChats(chats) + updatingChatsMutex.withLock { + val chats = apiGetChats() + chatModel.updateChats(chats) + } } private fun startReceiver() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index fa0f8f54d1..0cca354746 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -20,11 +20,13 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.res.MR +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.* import java.io.* import java.net.URI @@ -620,8 +622,10 @@ private fun afterSetCiTTL( appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) withApi { try { - val chats = m.controller.apiGetChats() - m.updateChats(chats) + updatingChatsMutex.withLock { + val chats = m.controller.apiGetChats() + m.updateChats(chats) + } } catch (e: Exception) { Log.e(TAG, "apiGetChats error: ${e.message}") } From 675fc19745960db809e77ced0554b6947ae3187b Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 13 Oct 2023 07:05:06 +0800 Subject: [PATCH 26/80] rfc: desktop calls (#3208) * rfc: desktop calls * errors * html * link * screen sharing * additions * addition * correction --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- docs/rfcs/2023-10-12-desktop-calls.md | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/rfcs/2023-10-12-desktop-calls.md diff --git a/docs/rfcs/2023-10-12-desktop-calls.md b/docs/rfcs/2023-10-12-desktop-calls.md new file mode 100644 index 0000000000..c956ed0115 --- /dev/null +++ b/docs/rfcs/2023-10-12-desktop-calls.md @@ -0,0 +1,34 @@ +# Desktop calls + +To make audio and video calls on desktop there are some options: +- adapt [libwebrtc](webrtc.googlesource.com/) from Google which would be the most reliable, performant and seamless solution; +- include some kind of WebView to the app via libraries; +- implement a signaling server in the app and let users to use HTML page with WebRTC code that already exist for Android. + +## WebRTC lib + +To adapt libwebrtc we need to make SDK that is compatible with Java (JNI layer + desktop implementation of VideoCodecs and other features). There are two SDKs exist already: for Android and for Objective-C. Making Android SDK compatible with Java-only SDK gives a lot of problems and requires to have 5+ professional C++ developers with weeks/months for developing. Which is not something good. + +Another considered option was adopting Jitsi Java SDK, but it's state is not very clear, and in any case it is much more effort. + +## WebView + +Including WebView is possible but requires a lot of megabytes of storage to waste on such libs. Because only Chromium-like WebViews support WebRTC features. Which means 100+ MB to the package on top of 200+ MB now. + +## Standalone browser + WebRTC HTML page + +The last solution is what can give the most useful result: the same package size as before + already existent code which can be reused with small modifications + quality of result will depend on Chromium/Firefox/Safari devs (which is good, since they are interested in making all features working for everyone). + +# Details of implementation + +Since the code for WebView has already written (https://github.com/simplex-chat/simplex-chat/tree/0e4376bada2d0c4ec2ade7f30b0048dc8b13abd8/apps/multiplatform/android/src/main/assets/www) it can be used to make calls on a desktop browser too. The only differences are these: +- UI needs to be changed - buttons controlling calls should be added. For example, end call, disable camera/mic. This will be added to separate HTML and JS files that would communication with the existing WebRTC JS code via the existing functional API. +- signaling websocket server should be started in order to allow Haskell backend to talk with HTML page and to exchange messages between all parties. It is bidirectional communication between server and webpage since both parties have to send messages to each other. + +There is no need to have TLS-secured connection between server and webpage since it's only used locally. And browser will not be happy with self-signed certificate too. + +After accepting the call, webpage will be opened in the default browser. URL will look like: `https://localhost:123/simplex/call/` (to reduce questions and to namespace other files - the main UI page will be index.html, that would load via folder path) After that internal machinary will connect both parties together. Same as on Android but with a different signaling channel, in this case it's websockets. + +Ending the call by the user will also send a new action to signaling server to allow the backend to notify other party. + +The solution will also allow to make screen sharing in the future that will be supported on every OS and graphics environment where the user's browser supports it. \ No newline at end of file From ab290fb0683047f706f49d5b35052c44873518f7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 11:51:01 +0100 Subject: [PATCH 27/80] core: track network statuses, use in commands/events (#3211) * core: track network statuses, use in commands/events * ui types, test * remove comment --- apps/ios/Shared/Model/ChatModel.swift | 38 ---------- apps/ios/Shared/Model/SimpleXAPI.swift | 27 +++++--- apps/ios/SimpleXChat/APITypes.swift | 59 ++++++++++++++-- apps/ios/SimpleXChat/ChatTypes.swift | 12 +++- .../chat/simplex/common/model/ChatModel.kt | 20 ++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 39 ++++++++--- src/Simplex/Chat.hs | 69 ++++++++++++++----- src/Simplex/Chat/Controller.hs | 15 +++- src/Simplex/Chat/Mobile.hs | 3 +- src/Simplex/Chat/Types.hs | 37 +++++++++- src/Simplex/Chat/View.hs | 10 ++- tests/ChatTests/Direct.hs | 16 +++++ tests/MobileTests.hs | 32 ++++----- 13 files changed, 275 insertions(+), 102 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1ec2f8f53..ec1a567420 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -688,41 +688,3 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } - -enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(String) - - var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } - } - - var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } -} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 85e66e893d..e4f64ee977 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -944,6 +944,12 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } +func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { + let r = chatSendCmdSync(.apiGetNetworkStatuses) + if case let .networkStatuses(_, statuses) = r { return statuses } + throw r +} + func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async { do { if chat.chatStats.unreadCount > 0 { @@ -1348,13 +1354,6 @@ func processReceivedMsg(_ res: ChatResponse) async { await updateContactsStatus(contactRefs, status: .connected) case let .contactsDisconnected(_, contactRefs): await updateContactsStatus(contactRefs, status: .disconnected) - case let .contactSubError(user, contact, chatError): - await MainActor.run { - if active(user) { - m.updateContact(contact) - } - processContactSubError(contact, chatError) - } case let .contactSubSummary(_, contactSubscriptions): await MainActor.run { for sub in contactSubscriptions { @@ -1369,6 +1368,18 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .networkStatus(status, connections): + await MainActor.run { + for cId in connections { + m.networkStatuses[cId] = status + } + } + case let .networkStatuses(statuses): () + await MainActor.run { + for s in statuses { + m.networkStatuses[s.agentConnId] = s.networkStatus + } + } case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem @@ -1649,7 +1660,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.setContactNetworkStatus(contact, .error(err)) + m.setContactNetworkStatus(contact, .error(connectionError: err)) } func refreshCallInvitations() throws { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d65b9b3283..d29e2481d5 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -110,6 +110,7 @@ public enum ChatCommand { case apiEndCall(contact: Contact) case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) + case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) @@ -241,6 +242,7 @@ public enum ChatCommand { case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" + case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" case let .receiveFile(fileId, encrypted, inline): @@ -356,6 +358,7 @@ public enum ChatCommand { case .apiEndCall: return "apiEndCall" case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" + case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" case .apiChatUnread: return "apiChatUnread" case .receiveFile: return "receiveFile" @@ -480,11 +483,14 @@ public enum ChatResponse: Decodable, Error { case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef) case contactUpdated(user: UserRef, toContact: Contact) + // TODO remove events below case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(user: UserRef, contact: Contact, chatError: ChatError) case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(user: UserRef, groupInfo: GroupInfo) + // TODO remove events above + case networkStatus(networkStatus: NetworkStatus, connections: [String]) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case groupSubscribed(user: UserRef, groupInfo: GroupRef) case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed @@ -620,8 +626,9 @@ public enum ChatResponse: Decodable, Error { case .contactUpdated: return "contactUpdated" case .contactsSubscribed: return "contactsSubscribed" case .contactsDisconnected: return "contactsDisconnected" - case .contactSubError: return "contactSubError" case .contactSubSummary: return "contactSubSummary" + case .networkStatus: return "networkStatus" + case .networkStatuses: return "networkStatuses" case .groupSubscribed: return "groupSubscribed" case .memberSubErrors: return "memberSubErrors" case .groupEmpty: return "groupEmpty" @@ -757,8 +764,9 @@ public enum ChatResponse: Decodable, Error { case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))") case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) + case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) @@ -1181,6 +1189,49 @@ public struct KeepAliveOpts: Codable, Equatable { public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4) } +public enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(connectionError: String) + + public var statusString: LocalizedStringKey { + get { + switch self { + case .connected: return "connected" + case .error: return "error" + default: return "connecting" + } + } + } + + public var statusExplanation: LocalizedStringKey { + get { + switch self { + case .connected: return "You are connected to the server used to receive messages from this contact." + case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: return "Trying to connect to the server used to receive messages from this contact." + } + } + } + + public var imageName: String { + get { + switch self { + case .unknown: return "circle.dotted" + case .connected: return "circle.fill" + case .disconnected: return "ellipsis.circle.fill" + case .error: return "exclamationmark.circle.fill" + } + } + } +} + +public struct ConnNetworkStatus: Decodable { + public var agentConnId: String + public var networkStatus: NetworkStatus +} + public struct ChatSettings: Codable { public var enableNtfs: MsgFilter public var sendRcpts: Bool? diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 37e4f0316a..caaccb5454 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1729,6 +1729,11 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { ) } +public struct GroupRef: Decodable { + public var groupId: Int64 + var localDisplayName: GroupName +} + public struct GroupProfile: Codable, NamedChat { public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { self.displayName = displayName @@ -1871,6 +1876,11 @@ public struct GroupMemberRef: Decodable { var profile: Profile } +public struct GroupMemberIds: Decodable { + var groupMemberId: Int64 + var groupId: Int64 +} + public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { case observer = "observer" case member = "member" @@ -1963,7 +1973,7 @@ public enum InvitedBy: Decodable { } public struct MemberSubError: Decodable { - var member: GroupMember + var member: GroupMemberIds var memberError: ChatError } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ac5f9fb8a5..a4b76cc79d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -789,16 +789,19 @@ sealed class NetworkStatus { val statusExplanation: String get() = when (this) { is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), error) + is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError) else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) } @Serializable @SerialName("unknown") class Unknown: NetworkStatus() @Serializable @SerialName("connected") class Connected: NetworkStatus() @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() + @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus() } +@Serializable +data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus) + @Serializable data class Contact( val contactId: Long, @@ -1051,6 +1054,9 @@ data class GroupInfo ( } } +@Serializable +data class GroupRef(val groupId: Long, val localDisplayName: String) + @Serializable data class GroupProfile ( override val displayName: String, @@ -1159,11 +1165,17 @@ data class GroupMember ( data class GroupMemberSettings(val showMessages: Boolean) {} @Serializable -class GroupMemberRef( +data class GroupMemberRef( val groupMemberId: Long, val profile: Profile ) +@Serializable +data class GroupMemberIds( + val groupMemberId: Long, + val groupId: Long +) + @Serializable enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @@ -1257,7 +1269,7 @@ class LinkPreview ( @Serializable class MemberSubError ( - val member: GroupMember, + val member: GroupMemberIds, val memberError: ChatError ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index dc2c3c0f25..e19520093c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1082,6 +1082,13 @@ object ChatController { return r is CR.CmdOk } + suspend fun apiGetNetworkStatuses(): List? { + val r = sendCmd(CC.ApiGetNetworkStatuses()) + if (r is CR.NetworkStatuses) return r.networkStatuses + Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { val r = sendCmd(CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true @@ -1425,12 +1432,6 @@ object ChatController { } is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) - is CR.ContactSubError -> { - if (active(r.user)) { - chatModel.updateContact(r.contact) - } - processContactSubError(r.contact, r.chatError) - } is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { @@ -1444,6 +1445,16 @@ object ChatController { } } } + is CR.NetworkStatusResp -> { + for (cId in r.connections) { + chatModel.networkStatuses[cId] = r.networkStatus + } + } + is CR.NetworkStatuses -> { + for (s in r.networkStatuses) { + chatModel.networkStatuses[s.agentConnId] = s.networkStatus + } + } is CR.NewChatItem -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem @@ -1915,6 +1926,7 @@ sealed class CC { class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiEndCall(val contact: Contact): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() + class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() @@ -2024,6 +2036,7 @@ sealed class CC { is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" + is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") @@ -2120,6 +2133,7 @@ sealed class CC { is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiEndCall -> "apiEndCall" is ApiCallStatus -> "apiCallStatus" + is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" @@ -3333,11 +3347,14 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() + // TODO remove below @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR() @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR() + // TODO remove above + @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() + @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() + @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR() @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR() @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @@ -3467,8 +3484,9 @@ sealed class CR { is ContactUpdated -> "contactUpdated" is ContactsSubscribed -> "contactsSubscribed" is ContactsDisconnected -> "contactsDisconnected" - is ContactSubError -> "contactSubError" is ContactSubSummary -> "contactSubSummary" + is NetworkStatusResp -> "networkStatus" + is NetworkStatuses -> "networkStatuses" is GroupSubscribed -> "groupSubscribed" is MemberSubErrors -> "memberSubErrors" is GroupEmpty -> "groupEmpty" @@ -3596,8 +3614,9 @@ sealed class CR { is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}") is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) + is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" + is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) is GroupSubscribed -> withUser(user, json.encodeToString(group)) is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) is GroupEmpty -> withUser(user, json.encodeToString(group)) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b7421d9366..56077c3bf6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -143,7 +143,8 @@ defaultChatConfig = initialCleanupManagerDelay = 30 * 1000000, -- 30 seconds cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds - ciExpirationInterval = 30 * 60 * 1000000 -- 30 minutes + ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes + coreApi = False } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -195,6 +196,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg <- newTVarIO =<< liftIO drgNew inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize + connNetworkStatuses <- atomically TM.empty subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty @@ -221,6 +223,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg, inputQ, outputQ, + connNetworkStatuses, subscriptionMode, chatLock, sndFiles, @@ -1086,6 +1089,8 @@ processChatCommand = \case user <- getUserByContactId db contactId contact <- getContact db user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} + APIGetNetworkStatuses -> withUser $ \_ -> + CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call @@ -1688,6 +1693,8 @@ processChatCommand = \case (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode -- [incognito] reuse membership incognito profile ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode + -- TODO not sure it is correct to set connections status here? + setContactNetworkStatus ct NSConnected pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do @@ -2627,6 +2634,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce +-- TODO possibly, we could either disable these events or replace with less noisy for API contactLinkSubsToView rs ucs groupSubsToView rs gs ms ce sndFileSubsToView rs sfts @@ -2687,12 +2695,30 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m () - contactSubsToView rs cts ce = do - toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + contactSubsToView rs cts ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI where - cRs = resultsFor rs cts - cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + notifyCLI = do + let cRs = resultsFor rs cts + cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs + when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + notifyAPI = do + let statuses = M.foldrWithKey' addStatus [] cts + chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) + toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses + where + addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] + addStatus connId ct = + let ns = (contactAgentConnId ct, netStatus $ resultErr connId rs) + in (ns :) + netStatus :: Maybe ChatError -> NetworkStatus + netStatus = maybe NSConnected $ NSError . errorNetworkStatus + errorNetworkStatus :: ChatError -> String + errorNetworkStatus = \case + ChatErrorAgent (BROKER _ NETWORK) _ -> "network" + ChatErrorAgent (SMP SMP.AUTH) _ -> "contact deleted" + e -> show e +-- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () @@ -2742,12 +2768,12 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do resultsFor rs = M.foldrWithKey' addResult [] where addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)] - addResult connId = (:) . (,err) - where - err = case M.lookup connId rs of - Just (Left e) -> Just $ ChatErrorAgent e Nothing - Just _ -> Nothing - _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId + addResult connId = (:) . (,resultErr connId rs) + resultErr :: ConnId -> Map ConnId (Either AgentErrorType ()) -> Maybe ChatError + resultErr connId rs = case M.lookup connId rs of + Just (Left e) -> Just $ ChatErrorAgent e Nothing + Just _ -> Nothing + _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId cleanupManager :: forall m. ChatMonad m => m () cleanupManager = do @@ -2892,16 +2918,22 @@ processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns CRContactsDisconnected - UP srv conns -> serverEvent srv conns CRContactsSubscribed + DOWN srv conns -> serverEvent srv conns NSDisconnected CRContactsDisconnected + UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed SUSPENDED -> toView CRChatSuspended DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv conns event = do - cs <- withStore' (`getConnectionsContacts` conns) - toView $ event srv cs + serverEvent srv conns nsStatus event = ifM (asks $ coreApi . config) notifyAPI notifyCLI + where + notifyAPI = do + let connIds = map AgentConnId conns + chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds + toView $ CRNetworkStatus nsStatus connIds + notifyCLI = do + cs <- withStore' (`getConnectionsContacts` conns) + toView $ event srv cs processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m () processAgentMsgSndFile _corrId aFileId msg = @@ -3188,6 +3220,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> do -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + setContactNetworkStatus ct NSConnected toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct when (contactConnInitiated conn) $ do @@ -3762,6 +3795,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m () notifyMemberConnected gInfo m ct_ = do memberConnectedChatItem gInfo m + mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CRConnectedToGroupMember user gInfo m ct_ probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () @@ -5569,6 +5603,7 @@ chatCommandP = "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, + "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index af9f34d2d4..2704c2721f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -32,6 +32,7 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) @@ -124,7 +125,8 @@ data ChatConfig = ChatConfig initialCleanupManagerDelay :: Int64, cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, - ciExpirationInterval :: Int64 -- microseconds + ciExpirationInterval :: Int64, -- microseconds + coreApi :: Bool } data DefaultAgentServers = DefaultAgentServers @@ -164,6 +166,7 @@ data ChatController = ChatController idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, ChatResponse), + connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), @@ -251,6 +254,7 @@ data ChatCommand | APIEndCall ContactId | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus + | APIGetNetworkStatuses | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias @@ -528,6 +532,8 @@ data ChatResponse | CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError} | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} + | CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} + | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} @@ -1044,6 +1050,13 @@ chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) {-# INLINE chatWriteVar #-} +chatModifyVar :: ChatMonad' m => (ChatController -> TVar a) -> (a -> a) -> m () +chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue) +{-# INLINE chatModifyVar #-} + +setContactNetworkStatus :: ChatMonad' m => Contact -> NetworkStatus -> m () +setContactNetworkStatus ct = chatModifyVar connNetworkStatuses . M.insert (contactAgentConnId ct) + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 0a970d2c8e..b444888145 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -169,7 +169,8 @@ defaultMobileConfig :: ChatConfig defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, - logLevel = CLLError + logLevel = CLLError, + coreApi = True } getActiveUser_ :: SQLiteStore -> IO (Maybe User) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 83d0664a04..9992f96ab0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -192,6 +192,9 @@ instance ToJSON Contact where contactConn :: Contact -> Connection contactConn Contact {activeConn} = activeConn +contactAgentConnId :: Contact -> AgentConnId +contactAgentConnId Contact {activeConn = Connection {agentConnId}} = agentConnId + contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn @@ -1140,13 +1143,16 @@ liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft fp RcvFileInfo {filePath} = filePath newtype AgentConnId = AgentConnId ConnId - deriving (Eq, Show) + deriving (Eq, Ord, Show) instance StrEncoding AgentConnId where strEncode (AgentConnId connId) = strEncode connId strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP +instance FromJSON AgentConnId where + parseJSON = strParseJSON "AgentConnId" + instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1477,6 +1483,35 @@ serializeIntroStatus = \case textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode +data NetworkStatus + = NSUnknown + | NSConnected + | NSDisconnected + | NSError {connectionError :: String} + deriving (Eq, Ord, Show, Generic) + +netStatusStr :: NetworkStatus -> String +netStatusStr = \case + NSUnknown -> "unknown" + NSConnected -> "connected" + NSDisconnected -> "disconnected" + NSError e -> "error: " <> e + +instance FromJSON NetworkStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS" + +instance ToJSON NetworkStatus where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS" + +data ConnNetworkStatus = ConnNetworkStatus + { agentConnId :: AgentConnId, + networkStatus :: NetworkStatus + } + deriving (Show, Generic, FromJSON) + +instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions + type CommandId = Int64 aCorrId :: CommandId -> ACorrId diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 86a10988d6..bcbc45f4eb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -19,7 +19,7 @@ import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -210,6 +210,8 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks + CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] + CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRGroupInvitation u g -> ttyUser u [groupInvitation' g] CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g @@ -800,6 +802,12 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] +viewNetworkStatuses :: [ConnNetworkStatus] -> [StyledString] +viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortOn netStatus + where + netStatus ConnNetworkStatus {networkStatus} = networkStatus + viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) + viewUserJoinedGroup :: GroupInfo -> [StyledString] viewUserJoinedGroup g = case incognitoMembershipProfile g of diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index b4c3c53cd9..677bc4c09d 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -119,6 +119,8 @@ chatDirectTests = do testReqVRange vr11 supportedChatVRange testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange + describe "network statuses" $ do + it "should get network statuses" testGetNetworkStatuses where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -2623,6 +2625,20 @@ testUpdatePeerChatVRange tmp = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig +testGetNetworkStatuses :: HasCallStack => FilePath -> IO () +testGetNetworkStatuses tmp = do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + alice ##> "/_network_statuses" + alice <## "1 connections connected" + withTestChatCfg tmp cfg "alice" $ \alice -> + withTestChatCfg tmp cfg "bob" $ \bob -> do + alice <## "1 connections connected" + bob <## "1 connections connected" + where + cfg = testCfg {coreApi = True} + vr11 :: VersionRange vr11 = mkVersionRange 1 1 diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 692dd5884e..aa36b397a3 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -120,19 +120,19 @@ chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" chatStartedTagged :: LB.ByteString chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" -contactSubSummary :: LB.ByteString -contactSubSummary = +networkStatuses :: LB.ByteString +networkStatuses = #if defined(darwin_HOST_OS) && defined(swiftJSON) - contactSubSummarySwift + networkStatusesSwift #else - contactSubSummaryTagged + networkStatusesTagged #endif -contactSubSummarySwift :: LB.ByteString -contactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" +networkStatusesSwift :: LB.ByteString +networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" -contactSubSummaryTagged :: LB.ByteString -contactSubSummaryTagged = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" +networkStatusesTagged :: LB.ByteString +networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" memberSubSummary :: LB.ByteString memberSubSummary = @@ -143,10 +143,10 @@ memberSubSummary = #endif memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" userContactSubSummary :: LB.ByteString userContactSubSummary = @@ -157,10 +157,10 @@ userContactSubSummary = #endif userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" pendingSubSummary :: LB.ByteString pendingSubSummary = @@ -171,13 +171,13 @@ pendingSubSummary = #endif pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" userJSON :: LB.ByteString -userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" parsedMarkdown :: LB.ByteString parsedMarkdown = @@ -215,7 +215,7 @@ testChatApi tmp = do chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted - chatRecvMsg cc `shouldReturn` contactSubSummary + chatRecvMsg cc `shouldReturn` networkStatuses chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsg cc `shouldReturn` memberSubSummary chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary From 07047a3ef365bf779bb2d74281e37e45a2cdf57d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 14:29:48 +0100 Subject: [PATCH 28/80] ios: fix pattern match --- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e4f64ee977..18e7f7efbb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1374,7 +1374,7 @@ func processReceivedMsg(_ res: ChatResponse) async { m.networkStatuses[cId] = status } } - case let .networkStatuses(statuses): () + case let .networkStatuses(_, statuses): () await MainActor.run { for s in statuses { m.networkStatuses[s.agentConnId] = s.networkStatus From c609303348784a23a134424faaad1e2813b247ab Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 13 Oct 2023 19:19:00 +0400 Subject: [PATCH 29/80] ios: connect plan (#3205) * ios: connect plan * improvements * wording * fixes * rework to use dismissAllSheets with callback * rework * update texts * Update apps/ios/Shared/Views/NewChat/NewChatButton.swift * Update apps/ios/Shared/Views/NewChat/NewChatButton.swift --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/ContentView.swift | 37 +- apps/ios/Shared/Model/ChatModel.swift | 10 + apps/ios/Shared/Model/SimpleXAPI.swift | 21 +- .../Chat/Group/GroupMemberInfoView.swift | 35 +- .../Shared/Views/NewChat/NewChatButton.swift | 346 ++++++++++++++++-- .../Views/NewChat/PasteToConnectView.swift | 18 +- .../Views/NewChat/ScanToConnectView.swift | 18 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 30 +- apps/ios/SimpleXChat/APITypes.swift | 34 ++ 9 files changed, 437 insertions(+), 112 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 3dbcf47004..04eadac2c1 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -31,11 +31,11 @@ struct ContentView: View { @State private var chatListActionSheet: ChatListActionSheet? = nil private enum ChatListActionSheet: Identifiable { - case connectViaUrl(action: ConnReqType, link: String) + case planAndConnectSheet(sheet: PlanAndConnectActionSheet) var id: String { switch self { - case let .connectViaUrl(_, link): return "connectViaUrl \(link)" + case let .planAndConnectSheet(sheet): return sheet.id } } } @@ -93,7 +93,7 @@ struct ContentView: View { mainView() .actionSheet(item: $chatListActionSheet) { sheet in switch sheet { - case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link) + case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) } } } else { @@ -290,12 +290,19 @@ struct ContentView: View { if let url = m.appOpenUrl { m.appOpenUrl = nil var path = url.path - logger.debug("ContentView.connectViaUrl path: \(path)") if (path == "/contact" || path == "/invitation") { path.removeFirst() - let action: ConnReqType = path == "contact" ? .contact : .invitation - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - chatListActionSheet = .connectViaUrl(action: action, link: link) + // TODO normalize in backend; revert + // let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + var link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + link = link.starts(with: "simplex:/") ? link.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") : link + planAndConnect( + link, + showAlert: showPlanAndConnectAlert, + showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) }, + dismiss: false, + incognito: nil + ) } else { AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) } @@ -303,20 +310,8 @@ struct ContentView: View { } } - private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet { - let title: LocalizedStringKey - switch action { - case .contact: title = "Connect via contact link" - case .invitation: title = "Connect via one-time link" - } - return ActionSheet( - title: Text(title), - buttons: [ - .default(Text("Use current profile")) { connectViaLink(link, incognito: false) }, - .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) }, - .cancel() - ] - ) + private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { + AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ec1a567420..b1a83dd5e6 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -152,6 +152,16 @@ final class ChatModel: ObservableObject { } } + func getGroupChat(_ groupId: Int64) -> Chat? { + chats.first { chat in + if case let .group(groupInfo) = chat.chatInfo { + return groupInfo.groupId == groupId + } else { + return false + } + } + } + private func getChatIndex(_ id: String) -> Int? { chats.firstIndex(where: { $0.id == id }) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 18e7f7efbb..a1c8cee774 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -586,6 +586,15 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P throw r } +func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { + logger.error("apiConnectPlan connReq: \(connReq)") + let userId = try currentUserId("apiConnectPlan") + let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) + if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } + logger.error("apiConnectPlan error: \(responseError(r))") + throw r +} + func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { @@ -610,10 +619,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } } - let alert = mkAlert( - title: "Contact already exists", - message: "You are already connected to \(contact.displayName)." - ) + let alert = contactAlreadyExistsAlert(contact) return (nil, alert) case .chatCmdError(_, .error(.invalidConnReq)): let alert = mkAlert( @@ -641,6 +647,13 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert return (nil, alert) } +func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connected to \(contact.displayName)." + ) +} + private func connectionErrorAlert(_ r: ChatResponse) -> Alert { if let networkErrorAlert = networkErrorAlert(r) { return networkErrorAlert diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 5ec14f5be1..3d10101dee 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -19,7 +19,7 @@ struct GroupMemberInfoView: View { @State private var connectionCode: String? = nil @State private var newRole: GroupMemberRole = .member @State private var alert: GroupMemberInfoViewAlert? - @State private var connectToMemberDialog: Bool = false + @State private var sheet: PlanAndConnectActionSheet? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var justOpened = true @State private var progressIndicator = false @@ -30,9 +30,8 @@ struct GroupMemberInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert - case connRequestSentAlert(type: ConnReqType) + case planAndConnectAlert(alert: PlanAndConnectAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey) - case other(alert: Alert) var id: String { switch self { @@ -41,9 +40,8 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" - case .connRequestSentAlert: return "connRequestSentAlert" + case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .error(title, _): return "error \(title)" - case let .other(alert): return "other \(alert)" } } } @@ -206,11 +204,11 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) - case let .connRequestSentAlert(type): return connReqSentAlert(type) + case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) - case let .other(alert): return alert } } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } if progressIndicator { ProgressView().scaleEffect(2) @@ -220,25 +218,16 @@ struct GroupMemberInfoView: View { func connectViaAddressButton(_ contactLink: String) -> some View { Button { - connectToMemberDialog = true + planAndConnect( + contactLink, + showAlert: { alert = .planAndConnectAlert(alert: $0) }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: nil + ) } label: { Label("Connect", systemImage: "link") } - .confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) { - Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) } - Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) } - } - } - - func connectViaAddress(incognito: Bool, contactLink: String) { - Task { - let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink) - if let connReqType = connReqType { - alert = .connRequestSentAlert(type: connReqType) - } else if let connectAlert = connectAlert { - alert = .other(alert: connectAlert) - } - } } func knownDirectChatButton(_ chat: Chat) -> some View { diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index a727ad6be0..e4fbd8bd47 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -58,65 +58,331 @@ struct NewChatButton: View { } } -enum ConnReqType: Equatable { - case contact - case invitation +enum PlanAndConnectAlert: Identifiable { + case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case invitationLinkConnecting(connectionLink: String) + case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) + + var id: String { + switch self { + case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" + case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" + case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" + case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" + case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" + } + } } -func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) { - Task { - if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { - DispatchQueue.main.async { - dismiss?() - AlertManager.shared.showAlert(connReqSentAlert(connReqType)) - } +func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { + switch alert { + case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own one-time link!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case .invitationLinkConnecting: + return Alert( + title: Text("Already connecting!"), + message: Text("You are already connecting via this one-time link!") + ) + case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own SimpleX address!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Join group?"), + message: Text("You will connect to all group members."), + primaryButton: .default( + Text(incognito ? "Join incognito" : "Join"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConnecting(_, groupInfo): + if let groupInfo = groupInfo { + return Alert( + title: Text("Group already exists!"), + message: Text("You are already joining the group \(groupInfo.displayName).") + ) } else { - DispatchQueue.main.async { - dismiss?() + return Alert( + title: Text("Already joining the group!"), + message: Text("You are already joining the group via this link.") + ) + } + } +} + +enum PlanAndConnectActionSheet: Identifiable { + case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) + case ownLinkAskCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) + + var id: String { + switch self { + case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" + case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, _, _): return "ownLinkAskCurrentOrIncognitoProfile \(connectionLink)" + case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" + } + } +} + +func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet { + switch sheet { + case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo): + if let incognito = incognito { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + } + } +} + +func planAndConnect( + _ connectionLink: String, + showAlert: @escaping (PlanAndConnectAlert) -> Void, + showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, + dismiss: Bool, + incognito: Bool? +) { + Task { + do { + let connectionPlan = try await apiConnectPlan(connReq: connectionLink) + switch connectionPlan { + case let .invitationLink(ilp): + switch ilp { + case .ok: + logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + } + case .ownLink: + logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + } + case let .connecting(contact_): + logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") + if let contact = contact_ { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } else { + showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) + } + case let .known(contact): + logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .contactAddress(cap): + switch cap { + case .ok: + logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + } + case .ownLink: + logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + } + case let .connecting(contact): + logger.debug("planAndConnect, .contactAddress, .connecting, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + case let .known(contact): + logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .groupLink(glp): + switch glp { + case .ok: + if let incognito = incognito { + showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + } + case let .ownLink(groupInfo): + logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) + case let .connecting(groupInfo_): + logger.debug("planAndConnect, .groupLink, .connecting, incognito=\(incognito?.description ?? "nil")") + showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + case let .known(groupInfo): + logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + } + } + } catch { + logger.debug("planAndConnect, plan error") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) } } } } -struct CReqClientData: Decodable { - var type: String - var groupLinkId: String? -} - -func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? { - if let hashIndex = connectionLink.firstIndex(of: "#"), - let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])), - let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false), - let data = components.queryItems?.first(where: { $0.name == "data" })?.value, - let d = data.data(using: .utf8), - let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) { - return crData - } else { - return nil +private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { + Task { + if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { + let crt: ConnReqType + if let plan = connectionPlan { + crt = planToConnReqType(plan) + } else { + crt = connReqType + } + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } else { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } + } else { + if dismiss { + DispatchQueue.main.async { + dismissAllSheets(animated: true) + } + } + } } } -func checkCRDataGroup(_ crData: CReqClientData) -> Bool { - return crData.type == "group" && crData.groupLinkId != nil +func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let c = m.getContactChat(contact.contactId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } + } + } } -func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert { - return Alert( - title: Text("Connect via group link?"), - message: Text("You will join a group this link refers to and connect to its group members."), - primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) { - connectViaLink(connectionLink, incognito: incognito) - }, - secondaryButton: .cancel() +func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let g = m.getGroupChat(groupInfo.groupId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } + } + } +} + +func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connecting to \(contact.displayName)." ) } +func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert { + mkAlert( + title: "Group already exists", + message: "You are already in group \(groupInfo.displayName)." + ) +} + +enum ConnReqType: Equatable { + case invitation + case contact + case groupLink + + var connReqSentText: LocalizedStringKey { + switch self { + case .invitation: return "You will be connected when your contact's device is online, please wait or check later!" + case .contact: return "You will be connected when your connection request is accepted, please wait or check later!" + case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!" + } + } +} + +private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { + switch connectionPlan { + case .invitationLink: return .invitation + case .contactAddress: return .contact + case .groupLink: return .groupLink + } +} + func connReqSentAlert(_ type: ConnReqType) -> Alert { return mkAlert( title: "Connection request sent!", - message: type == .contact - ? "You will be connected when your connection request is accepted, please wait or check later!" - : "You will be connected when your contact's device is online, please wait or check later!" + message: type.connReqSentText ) } diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index ec199327ce..af84f19fec 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -14,6 +14,8 @@ struct PasteToConnectView: View { @State private var connectionLink: String = "" @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @FocusState private var linkEditorFocused: Bool + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { List { @@ -57,6 +59,8 @@ struct PasteToConnectView: View { + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") } } + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } private func linkEditor() -> some View { @@ -83,13 +87,13 @@ struct PasteToConnectView: View { private func connect() { let link = connectionLink.trimmingCharacters(in: .whitespaces) - if let crData = parseLinkQueryData(link), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault)) - } else { - connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault) - } + planAndConnect( + link, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 01c8a4659e..c55ba1502e 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -13,6 +13,8 @@ import CodeScanner struct ScanToConnectView: View { @Environment(\.dismiss) var dismiss: DismissAction @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { ScrollView { @@ -49,18 +51,20 @@ struct ScanToConnectView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .background(Color(.systemGroupedBackground)) + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } func processQRCode(_ resp: Result) { switch resp { case let .success(r): - if let crData = parseLinkQueryData(r.string), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault)) - } else { - Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) } - } + planAndConnect( + r.string, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") dismiss() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 35d0b1de8a..2a90e75d85 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -176,6 +176,11 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */; }; + 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; + 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; + 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C812AD6B6B900B21C4C /* libffi.a */; }; + 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; @@ -458,6 +463,11 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; + 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; + 64AB9C812AD6B6B900B21C4C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; @@ -507,13 +517,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */, - 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */, - 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, - 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, + 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, + 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */, + 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */, + 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +584,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CA8D0122AD746C8001FD661 /* libffi.a */, - 5CA8D0152AD746C8001FD661 /* libgmp.a */, - 5CA8D0112AD746C8001FD661 /* libgmpxx.a */, - 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, - 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, + 64AB9C812AD6B6B900B21C4C /* libffi.a */, + 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */, + 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */, + 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, + 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d29e2481d5..9eb9b9084c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -86,6 +86,7 @@ public enum ChatCommand { case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) case apiAddContact(userId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) @@ -219,6 +220,7 @@ public enum ChatCommand { case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" + case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" @@ -335,6 +337,7 @@ public enum ChatCommand { case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiAddContact: return "apiAddContact" case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" + case .apiConnectPlan: return "apiConnectPlan" case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" @@ -460,6 +463,7 @@ public enum ChatResponse: Decodable, Error { case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) + case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef) case sentInvitation(user: UserRef) case contactAlreadyExists(user: UserRef, contact: Contact) @@ -601,6 +605,7 @@ public enum ChatResponse: Decodable, Error { case .connectionVerified: return "connectionVerified" case .invitation: return "invitation" case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" + case .connectionPlan: return "connectionPlan" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" case .contactAlreadyExists: return "contactAlreadyExists" @@ -739,6 +744,7 @@ public enum ChatResponse: Decodable, Error { case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) @@ -859,6 +865,33 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { } } +public enum ConnectionPlan: Decodable { + case invitationLink(invitationLinkPlan: InvitationLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan) + case groupLink(groupLinkPlan: GroupLinkPlan) +} + +public enum InvitationLinkPlan: Decodable { + case ok + case ownLink + case connecting(contact_: Contact?) + case known(contact: Contact) +} + +public enum ContactAddressPlan: Decodable { + case ok + case ownLink + case connecting(contact: Contact) + case known(contact: Contact) +} + +public enum GroupLinkPlan: Decodable { + case ok + case ownLink(groupInfo: GroupInfo) + case connecting(groupInfo_: GroupInfo?) + case known(groupInfo: GroupInfo) +} + struct NewUser: Encodable { var profile: Profile? var sameServers: Bool @@ -1477,6 +1510,7 @@ public enum ChatErrorType: Decodable { case chatNotStarted case chatNotStopped case chatStoreChanged + case connectionPlan(connectionPlan: ConnectionPlan) case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) From a35dc263b78fa6a30dfe2b2702f82ff03469e9d2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Oct 2023 22:28:55 +0100 Subject: [PATCH 30/80] website: remove address from connect page --- website/src/_includes/contact_page.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index a03a3aea4f..6beb148f8d 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -152,7 +152,7 @@ v1.0.0+, {{ "copy-the-command-below-text" | i18n({}, lang ) | safe }}

- /c https://simplex.chat/contact#/?v=1&smp=smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im/KBCmxJ3-lEjpWLPPkI6OWPk-YJneU5uY%23MCowBQYDK2VuAyEAtixHJWDXvYWcoe-77vIfjvI6XWEuzUsapMS9nVHP_Go= +

From 838751fe782fdd72fe4d88267b6b75b903aff27a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 18:12:13 +0100 Subject: [PATCH 31/80] ios: fix Protect screen not hiding message previews (#3226) --- apps/ios/Shared/Views/ChatList/ChatPreviewView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 3ac8fada74..1e153baf44 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -105,14 +105,17 @@ struct ChatPreviewView: View { private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View { ZStack(alignment: .topTrailing) { - text + let t = text .lineLimit(2) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, 8) .padding(.trailing, 36) - .privacySensitive(!showChatPreviews && !draft) - .redacted(reason: .privacy) + if !showChatPreviews && !draft { + t.privacySensitive(true).redacted(reason: .privacy) + } else { + t + } let s = chat.chatStats if s.unreadCount > 0 || s.unreadChat { unreadCountText(s.unreadCount) From c2a320640b224dad03e05f72a1ecb836cb6fefbc Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 18:16:12 +0100 Subject: [PATCH 32/80] core: local encryption for auto-received inline files (e.g. small voice messages) (#3224) * core: local encryption for auto-received inline files * update view, test --- apps/ios/Shared/Model/SimpleXAPI.swift | 7 +++ .../ios/SimpleX NSE/NotificationService.swift | 7 +++ apps/ios/SimpleXChat/APITypes.swift | 23 +++++---- .../chat/simplex/common/model/SimpleXAPI.kt | 20 +++++++- src/Simplex/Chat.hs | 44 ++++++++++------- src/Simplex/Chat/Controller.hs | 6 ++- src/Simplex/Chat/View.hs | 49 ++++++++++--------- tests/ChatTests/Files.hs | 33 ++++++++++++- 8 files changed, 133 insertions(+), 56 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a1c8cee774..91c5b1b36d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -257,6 +257,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } +func apiSetEncryptLocalFiles(_ enable: Boolean) throws { + let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) + if case .cmdOk = r { return } + throw r +} + func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -1152,6 +1158,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(getXFTPCfg()) + // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 645fdb5952..2fb069a86c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -216,6 +216,7 @@ func startChat() -> DBMigrationResult? { try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) + // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) let justStarted = try apiStartChat() chatStarted = true if justStarted { @@ -351,6 +352,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } +func apiSetEncryptLocalFiles(_ enable: Boolean) throws { + let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) + if case .cmdOk = r { return } + throw r +} + func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { guard apiGetActiveUser() != nil else { logger.debug("no active user") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 9eb9b9084c..ddab4c73bd 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -32,6 +32,7 @@ public enum ChatCommand { case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) case apiSetXFTPConfig(config: XFTPFileConfig?) + case apiSetEncryptLocalFiles(enable: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -114,8 +115,8 @@ public enum ChatCommand { case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) - case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) - case setFileToReceive(fileId: Int64, encrypted: Bool) + case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?) + case setFileToReceive(fileId: Int64, encrypted: Bool?) case cancelFile(fileId: Int64) case showVersion case string(String) @@ -152,6 +153,7 @@ public enum ChatCommand { } else { return "/_xftp off" } + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -247,13 +249,8 @@ public enum ChatCommand { case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" - case let .receiveFile(fileId, encrypted, inline): - let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))" - if let inline = inline { - return s + " inline=\(onOff(inline))" - } - return s - case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" + case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" + case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))" case let .cancelFile(fileId): return "/fcancel \(fileId)" case .showVersion: return "/version" case let .string(str): return str @@ -283,6 +280,7 @@ public enum ChatCommand { case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" case .apiSetXFTPConfig: return "apiSetXFTPConfig" + case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -420,6 +418,13 @@ public enum ChatCommand { b ? "on" : "off" } + private func onOffParam(_ param: String, _ b: Bool?) -> String { + if let b = b { + return " \(param)=\(onOff(b))" + } + return "" + } + private func maybePwd(_ pwd: String?) -> String { pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index e19520093c..f49984b942 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -338,6 +338,7 @@ object ChatController { apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) apiSetXFTPConfig(getXFTPCfg()) +// apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() val users = listUsers() chatModel.users.clear() @@ -559,6 +560,8 @@ object ChatController { throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") } + suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -1327,6 +1330,13 @@ object ChatController { } } + private suspend fun sendCommandOkResp(cmd: CC): Boolean { + val r = sendCmd(cmd) + val ok = r is CR.CmdOk + if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r) + return ok + } + suspend fun apiGetVersion(): CoreVersionInfo? { val r = sendCmd(CC.ShowVersion()) return if (r is CR.VersionInfo) { @@ -1858,6 +1868,7 @@ sealed class CC { class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() + class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -1931,7 +1942,7 @@ sealed class CC { class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() - class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() + class ReceiveFile(val fileId: Long, val encrypt: Boolean?, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() @@ -1963,6 +1974,7 @@ sealed class CC { is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" + is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -2039,7 +2051,10 @@ sealed class CC { is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" - is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") + is ReceiveFile -> + "/freceive $fileId" + + (if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") + + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is ShowVersion -> "/version" } @@ -2063,6 +2078,7 @@ sealed class CC { is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" + is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 56077c3bf6..90a6617059 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -209,6 +209,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen cleanupManagerAsync <- newTVarIO Nothing timedItemThreads <- atomically TM.empty showLiveItems <- newTVarIO False + encryptLocalFiles <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir contactMergeEnabled <- newTVarIO True @@ -236,6 +237,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen cleanupManagerAsync, timedItemThreads, showLiveItems, + encryptLocalFiles, userXFTPFileConfig, tempDirectory, logFilePath = logFile, @@ -515,6 +517,7 @@ processChatCommand = \case APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ + APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ SetContactMergeEnabled onOff -> do asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) ok_ @@ -1773,19 +1776,16 @@ processChatCommand = \case ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" - ReceiveFile fileId encrypted rcvInline_ filePath_ -> withUser $ \_ -> + ReceiveFile fileId encrypted_ rcvInline_ filePath_ -> withUser $ \_ -> withChatLock "receiveFile" . procCmd $ do (user, ft) <- withStore (`getRcvFileTransferById` fileId) - ft' <- if encrypted then encryptLocalFile ft else pure ft + encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles + ft' <- (if encrypt then setFileToEncrypt else pure) ft receiveFile' user ft' rcvInline_ filePath_ - where - encryptLocalFile ft = do - cfArgs <- liftIO $ CF.randomArgs - withStore' $ \db -> setFileCryptoArgs db fileId cfArgs - pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} - SetFileToReceive fileId encrypted -> withUser $ \_ -> do + SetFileToReceive fileId encrypted_ -> withUser $ \_ -> do withChatLock "setFileToReceive" . procCmd $ do - cfArgs <- if encrypted then Just <$> liftIO CF.randomArgs else pure Nothing + encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles + cfArgs <- if encrypt then Just <$> liftIO CF.randomArgs else pure Nothing withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ CancelFile fileId -> withUser $ \user@User {userId} -> @@ -2410,6 +2410,12 @@ toFSFilePath :: ChatMonad' m => FilePath -> m FilePath toFSFilePath f = maybe f ( f) <$> (readTVarIO =<< asks filesFolder) +setFileToEncrypt :: ChatMonad m => RcvFileTransfer -> m RcvFileTransfer +setFileToEncrypt ft@RcvFileTransfer {fileId} = do + cfArgs <- liftIO CF.randomArgs + withStore' $ \db -> setFileCryptoArgs db fileId cfArgs + pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} + receiveFile' :: ChatMonad m => User -> RcvFileTransfer -> Maybe Bool -> Maybe FilePath -> m ChatResponse receiveFile' user ft rcvInline_ filePath_ = do (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchChatError` processError @@ -3931,14 +3937,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do inline <- receiveInlineMode fInv (Just mc) fileChunkSize ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP - (filePath, fileStatus) <- case inline of + (filePath, fileStatus, ft') <- case inline of Just IFMSent -> do + encrypt <- chatReadVar encryptLocalFiles + ft' <- (if encrypt then setFileToEncrypt else pure) ft fPath <- getRcvFilePath fileId Nothing fileName True - withStore' $ \db -> startRcvInlineFT db user ft fPath inline - pure (Just fPath, CIFSRcvAccepted) - _ -> pure (Nothing, CIFSRcvInvitation) - let fileSource = CF.plain <$> filePath - pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) + withStore' $ \db -> startRcvInlineFT db user ft' fPath inline + pure (Just fPath, CIFSRcvAccepted, ft') + _ -> pure (Nothing, CIFSRcvInvitation, ft) + let RcvFileTransfer {cryptoArgs} = ft' + fileSource = (`CryptoFile` cryptoArgs) <$> filePath + pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do @@ -5567,6 +5576,7 @@ chatCommandP = ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), + "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, @@ -5743,8 +5753,8 @@ chatCommandP = ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), - ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), - "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False)), + ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), + "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> optional (" encrypt=" *> onOffP)), ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/simplex" *> (ConnectSimplex <$> incognitoP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2704c2721f..0a9b886940 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -179,6 +179,7 @@ data ChatController = ChatController cleanupManagerAsync :: TVar (Maybe (Async ())), timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool, + encryptLocalFiles :: TVar Bool, userXFTPFileConfig :: TVar (Maybe XFTPFileConfig), tempDirectory :: TVar (Maybe FilePath), logFilePath :: Maybe FilePath, @@ -221,6 +222,7 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) + | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool | APIExportArchive ArchiveConfig | ExportArchive @@ -393,8 +395,8 @@ data ChatCommand | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath - | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} - | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Bool} + | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Maybe Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} + | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Maybe Bool} | CancelFile FileTransferId | FileStatus FileTransferId | ShowProfile -- UserId (not used in UI) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index bcbc45f4eb..08b1579bde 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -166,7 +166,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRRcvFileDescrReady _ _ -> [] CRRcvFileDescrNotReady _ _ -> [] CRRcvFileProgressXFTP {} -> [] - CRRcvFileAccepted u ci -> ttyUser u $ savingFile' testView ci + CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts CRRcvFileCancelled u _ ft -> ttyUser u $ receivingFile_ "cancelled" ft @@ -178,10 +178,10 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u ci e -> ttyUser u $ receivingFile_' "error" ci <> [sShow e] + CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e] CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileStartXFTP {} -> [] @@ -1449,27 +1449,28 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -savingFile' :: Bool -> AChatItem -> [StyledString] -savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath cfArgs_)}, chatDir}) = - let from = case (chat, chatDir) of - (DirectChat Contact {localDisplayName = c}, CIDirectRcv) -> " from " <> ttyContact c - (_, CIGroupRcv GroupMember {localDisplayName = m}) -> " from " <> ttyContact m - _ -> "" - in ["saving file " <> sShow fileId <> from <> " to " <> plain filePath] <> cfArgsStr - where - cfArgsStr = case cfArgs_ of - Just cfArgs@(CFArgs key nonce) - | testView -> [plain $ LB.unpack $ J.encode cfArgs] - | otherwise -> [plain $ "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce] - _ -> [] -savingFile' _ _ = ["saving file"] -- shouldn't happen +savingFile' :: AChatItem -> [StyledString] +savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath _)}, chatDir}) = + ["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath] +savingFile' _ = ["saving file"] -- shouldn't happen -receivingFile_' :: StyledString -> AChatItem -> [StyledString] -receivingFile_' status (AChatItem _ _ (DirectChat c) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact' c] -receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv m}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyMember m] -receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen +receivingFile_' :: Bool -> String -> AChatItem -> [StyledString] +receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) = + [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ + where + cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"] + where + s = + if testView + then LB.toStrict $ J.encode cfArgs + else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce + cfArgsStr _ = [] +receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen + +fileFrom :: ChatInfo c -> CIDirection c d -> StyledString +fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct +fileFrom _ (CIGroupRcv m) = " from " <> ttyMember m +fileFrom _ _ = "" receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 50f86d8e09..5231283903 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -33,6 +33,7 @@ chatFileTests = do describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer describe "send file, receive and locally encrypt file" $ fileTestMatrix2 runTestFileTransferEncrypted it "send and receive file inline (without accepting)" testInlineFileTransfer + it "send inline file, receive (without accepting) and locally encrypt" testInlineFileTransferEncrypted 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 @@ -107,7 +108,6 @@ runTestFileTransferEncrypted alice bob = do bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 encrypt=on ./tests/tmp" bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob concurrently_ (bob <## "started receiving file 1 (test.pdf) from alice") (alice <## "started sending file 1 (test.pdf) to bob") @@ -123,6 +123,7 @@ runTestFileTransferEncrypted alice bob = do "completed sending file 1 (test.pdf) to bob" ] ] + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob src <- B.readFile "./tests/fixtures/test.pdf" -- dest <- B.readFile "./tests/tmp/test.pdf" -- dest `shouldBe` src @@ -154,6 +155,34 @@ testInlineFileTransfer = where cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} +testInlineFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testInlineFileTransferEncrypted = + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + bob ##> "/_files_folder ./tests/tmp/" + bob <## "ok" + bob ##> "/_files_encrypt on" + bob <## "ok" + alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" + alice <# "@bob voice message (00:10)" + alice <# "/f @bob ./tests/fixtures/test.jpg" + -- below is not shown in "sent" mode + -- alice <## "use /fc 1 to cancel sending" + bob <# "alice> voice message (00:10)" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + -- below is not shown in "sent" mode + -- bob <## "use /fr 1 [/ | ] to receive it" + bob <## "started receiving file 1 (test.jpg) from alice" + concurrently_ + (alice <## "completed sending file 1 (test.jpg) to bob") + (bob <## "completed receiving file 1 (test.jpg) from alice") + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob + src <- B.readFile "./tests/fixtures/test.jpg" + Right dest <- chatReadFile "./tests/tmp/test.jpg" (strEncode key) (strEncode nonce) + LB.toStrict dest `shouldBe` src + where + cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} + testAcceptInlineFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO () testAcceptInlineFileSndCancelDuringTransfer = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -1077,10 +1106,10 @@ testXFTPFileTransferEncrypted = bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 encrypt=on ./tests/tmp/bob/" bob <## "saving file 1 from alice to ./tests/tmp/bob/test.pdf" - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob alice <## "completed uploading file 1 (test.pdf) for bob" bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src From 43b67ba157dab55d24d200a62aba42c580499d6e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 15 Oct 2023 20:58:39 +0100 Subject: [PATCH 33/80] ui: set local file encryption in the core (#3227) --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 ++-- .../Shared/Views/UserSettings/PrivacySettings.swift | 13 +++++++++++++ apps/ios/SimpleX NSE/NotificationService.swift | 6 +++--- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 6 +++--- .../common/views/usersettings/PrivacySettings.kt | 4 +++- .../src/commonMain/resources/MR/base/strings.xml | 1 + 6 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 91c5b1b36d..2e724bcd83 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -257,7 +257,7 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetEncryptLocalFiles(_ enable: Boolean) throws { +func apiSetEncryptLocalFiles(_ enable: Bool) throws { let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } throw r @@ -1158,7 +1158,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(getXFTPCfg()) - // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) + try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 34b6f147bd..16555fad3a 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -66,6 +66,9 @@ struct PrivacySettings: View { Section { settingsRow("lock.doc") { Toggle("Encrypt local files", isOn: $encryptLocalFiles) + .onChange(of: encryptLocalFiles) { + setEncryptLocalFiles($0) + } } settingsRow("photo") { Toggle("Auto-accept images", isOn: $autoAcceptImages) @@ -183,6 +186,16 @@ struct PrivacySettings: View { } } + private func setEncryptLocalFiles(_ enable: Bool) { + do { + try apiSetEncryptLocalFiles(enable) + } catch let error { + let err = responseError(error) + logger.error("apiSetEncryptLocalFiles \(err)") + alert = .error(title: "Error", error: "\(err)") + } + } + private func setOrAskSendReceiptsContacts(_ enable: Bool) { contactReceiptsOverrides = m.chats.reduce(0) { count, chat in let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 2fb069a86c..ea52f4be89 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -216,7 +216,7 @@ func startChat() -> DBMigrationResult? { try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) - // try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) + try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) let justStarted = try apiStartChat() chatStarted = true if justStarted { @@ -352,8 +352,8 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetEncryptLocalFiles(_ enable: Boolean) throws { - let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) +func apiSetEncryptLocalFiles(_ enable: Bool) throws { + let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) if case .cmdOk = r { return } throw r } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index f49984b942..cc8d481b59 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -338,7 +338,7 @@ object ChatController { apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) apiSetXFTPConfig(getXFTPCfg()) -// apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) + apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() val users = listUsers() chatModel.users.clear() @@ -1333,7 +1333,7 @@ object ChatController { private suspend fun sendCommandOkResp(cmd: CC): Boolean { val r = sendCmd(cmd) val ok = r is CR.CmdOk - if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error), r) + if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok } @@ -1942,7 +1942,7 @@ sealed class CC { class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() - class ReceiveFile(val fileId: Long, val encrypt: Boolean?, val inline: Boolean?): CC() + class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index ef0940b2a0..210d3c6136 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -64,7 +64,9 @@ fun PrivacySettingsView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_chats)) { - SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles) + SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, onChange = { enable -> + withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) } + }) SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) SettingsPreferenceItem( diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7912a5ad1b..114fe49e92 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -113,6 +113,7 @@ Server requires authorization to upload, check password Possibly, certificate fingerprint in server address is incorrect Error setting address + Error Connect Disconnect Create queue From 4b6df43e97ba7ac125529c409f254c4cddb8cdda Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:10:56 +0400 Subject: [PATCH 34/80] core: confirm to reconnect via address plan (#3212) * core: confirm to reconnect plan * rework query to prefer connections with contacts --- src/Simplex/Chat.hs | 25 +++++++++++++++---------- src/Simplex/Chat/Controller.hs | 12 ++++++++---- src/Simplex/Chat/Store/Connections.hs | 24 ++++++++++++++++++++++++ src/Simplex/Chat/Types.hs | 3 +++ src/Simplex/Chat/View.hs | 8 +++++--- tests/ChatTests/Groups.hs | 7 +++++++ tests/ChatTests/Profiles.hs | 5 +++++ 7 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 90a6617059..ef56e42056 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1364,13 +1364,13 @@ processChatCommand = \case APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) processChatCommand $ APIConnect userId incognito aCReqUri Connect _ Nothing -> throwChatError CEInvalidConnReq ConnectSimplex incognito -> withUser $ \user@User {userId} -> do let cReqUri = ACR SCMContact adminContactReq plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) - unless (connectionPlanOk plan) $ throwChatError (CEConnectionPlan plan) + unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) processChatCommand $ APIConnect userId incognito (Just cReqUri) DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect @@ -2245,27 +2245,32 @@ processChatCommand = \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - withStore' (\db -> getContactByConnReqHash db user cReqHash) >>= \case + withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHash) >>= \case Nothing -> pure $ CPContactAddress CAPOk - Just ct - | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnecting ct) + Just (RcvDirectMsgConnection _conn Nothing) -> pure $ CPContactAddress CAPConnectingConfirmReconnect + Just (RcvDirectMsgConnection _ (Just ct)) + | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnectingProhibit ct) + | contactDeleted ct -> pure $ CPContactAddress CAPOk | otherwise -> pure $ CPContactAddress (CAPKnown ct) + Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" -- group link Just _ -> withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReq) >>= \case Just g -> pure $ CPGroupLink (GLPOwnLink g) Nothing -> do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - ct_ <- withStore' $ \db -> getContactByConnReqHash db user cReqHash + connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHash gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHash - case (gInfo_, ct_) of + case (gInfo_, connEnt_) of (Nothing, Nothing) -> pure $ CPGroupLink GLPOk - (Nothing, Just ct) - | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnecting gInfo_) + (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect + (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) + | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | otherwise -> pure $ CPGroupLink GLPOk + (Nothing, Just _) -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" (Just gInfo@GroupInfo {membership}, _) | not (memberActive membership) && not (memberRemoved membership) -> - pure $ CPGroupLink (GLPConnecting gInfo_) + pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) | otherwise -> pure $ CPGroupLink GLPOk diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0a9b886940..d8851ad87a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -647,7 +647,8 @@ instance ToJSON InvitationLinkPlan where data ContactAddressPlan = CAPOk | CAPOwnLink - | CAPConnecting {contact :: Contact} + | CAPConnectingConfirmReconnect + | CAPConnectingProhibit {contact :: Contact} | CAPKnown {contact :: Contact} deriving (Show, Generic) @@ -658,7 +659,8 @@ instance ToJSON ContactAddressPlan where data GroupLinkPlan = GLPOk | GLPOwnLink {groupInfo :: GroupInfo} - | GLPConnecting {groupInfo_ :: Maybe GroupInfo} + | GLPConnectingConfirmReconnect + | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo} deriving (Show, Generic) @@ -666,8 +668,8 @@ instance ToJSON GroupLinkPlan where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" -connectionPlanOk :: ConnectionPlan -> Bool -connectionPlanOk = \case +connectionPlanProceed :: ConnectionPlan -> Bool +connectionPlanProceed = \case CPInvitationLink ilp -> case ilp of ILPOk -> True ILPOwnLink -> True @@ -675,10 +677,12 @@ connectionPlanOk = \case CPContactAddress cap -> case cap of CAPOk -> True CAPOwnLink -> True + CAPConnectingConfirmReconnect -> True _ -> False CPGroupLink glp -> case glp of GLPOk -> True GLPOwnLink _ -> True + GLPConnectingConfirmReconnect -> True _ -> False newtype UserPwd = UserPwd {unUserPwd :: Text} diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 3ef77cbb65..d8ec38269f 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -10,6 +10,7 @@ module Simplex.Chat.Store.Connections ( getConnectionEntity, getConnectionEntityByConnReq, + getContactConnEntityByConnReqHash, getConnectionsToSubscribe, unsetConnectionToSubscribe, ) @@ -159,6 +160,29 @@ getConnectionEntityByConnReq db user cReq = do DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv = ? LIMIT 1" (Only cReq) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ +-- search connection for connection plan: +-- multiple connections can have same via_contact_uri_hash if request was repeated; +-- this function searches for latest connection with contact so that "known contact" plan would be chosen; +-- deleted connections are filtered out to allow re-connecting via same contact address +getContactConnEntityByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash db user cReqHash = do + connId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT agent_conn_id FROM ( + SELECT + agent_conn_id, + (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord + FROM connections + WHERE via_contact_uri_hash = ? AND conn_status != ? + ORDER BY conn_ord DESC, created_at DESC + LIMIT 1 + ) + |] + (cReqHash, ConnDeleted) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ + getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9992f96ab0..10b0c91d04 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -219,6 +219,9 @@ contactReady Contact {activeConn} = connReady activeConn contactActive :: Contact -> Bool contactActive Contact {contactStatus} = contactStatus == CSActive +contactDeleted :: Contact -> Bool +contactDeleted Contact {contactStatus} = contactStatus == CSDeleted + contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode activeConn diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 08b1579bde..fa2213d083 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1281,7 +1281,8 @@ viewConnectionPlan = \case CPContactAddress cap -> case cap of CAPOk -> [ctAddr "ok to connect"] CAPOwnLink -> [ctAddr "own address"] - CAPConnecting ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] + CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"] + CAPConnectingProhibit ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] CAPKnown ct -> [ ctAddr ("known contact " <> ttyContact' ct), "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" @@ -1291,8 +1292,9 @@ viewConnectionPlan = \case CPGroupLink glp -> case glp of GLPOk -> [grpLink "ok to connect"] GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] - GLPConnecting Nothing -> [grpLink "connecting"] - GLPConnecting (Just g) -> [grpLink ("connecting to group " <> ttyGroup' g)] + GLPConnectingConfirmReconnect -> [grpLink "connecting, allowed to reconnect"] + GLPConnectingProhibit Nothing -> [grpLink "connecting"] + GLPConnectingProhibit (Just g) -> [grpLink ("connecting to group " <> ttyGroup' g)] GLPKnown g -> [ grpLink ("known group " <> ttyGroup' g), "use " <> ttyToGroup g <> highlight' "" <> " to send messages" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4c31805265..43498711e7 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2397,8 +2397,15 @@ testPlanGroupLinkConnecting tmp = do alice ##> "/create link #team" getGroupLink alice "team" GRMember True withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 + bob ##> ("/c " <> gLink) bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting, allowed to reconnect" + + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <### [ "1 group links active", diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 80e1709222..5682d09ee8 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -651,8 +651,13 @@ testPlanAddressConnecting tmp = do getContactLink alice True withNewTestChat tmp "bob" bobProfile $ \bob -> do threadDelay 100000 + bob ##> ("/c " <> cLink) bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: connecting, allowed to reconnect" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" From 9ed31261e1d4e7d4e6aba449fa80fa5bf0ffbd46 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:16:03 +0400 Subject: [PATCH 35/80] core: check saved links and hashes by both connection request uri schemas for connection plan (#3233) --- src/Simplex/Chat.hs | 31 ++++++++++++++++++------- src/Simplex/Chat/Store/Connections.hs | 14 ++++++------ src/Simplex/Chat/Store/Groups.hs | 16 ++++++------- src/Simplex/Chat/Store/Profiles.hs | 8 +++---- tests/ChatTests/Direct.hs | 8 +++++++ tests/ChatTests/Groups.hs | 33 +++++++++++++++++++++++++++ tests/ChatTests/Profiles.hs | 28 ++++++++++++++++++++++- tests/ChatTests/Utils.hs | 8 +++++++ 8 files changed, 118 insertions(+), 28 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ef56e42056..e224f04ebb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2222,7 +2222,7 @@ processChatCommand = \case processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings connectPlan :: User -> AConnectionRequestUri -> m ConnectionPlan connectPlan user (ACR SCMInvitation cReq) = do - withStore' (\db -> getConnectionEntityByConnReq db user cReq) >>= \case + withStore' (\db -> getConnectionEntityByConnReq db user cReqSchemas) >>= \case Nothing -> pure $ CPInvitationLink ILPOk Just (RcvDirectMsgConnection conn ct_) -> do let Connection {connStatus, contactConnInitiated} = conn @@ -2235,17 +2235,23 @@ processChatCommand = \case Just ct -> pure $ CPInvitationLink (ILPKnown ct) Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + where + cReqSchemas :: (ConnReqInvitation, ConnReqInvitation) + cReqSchemas = case cReq of + (CRInvitationUri crData e2e) -> + ( CRInvitationUri crData {crScheme = CRSSimplex} e2e, + CRInvitationUri crData {crScheme = simplexChat} e2e + ) connectPlan user (ACR SCMContact cReq) = do let CRContactUri ConnReqUriData {crClientData} = cReq groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli case groupLinkId of -- contact address Nothing -> - withStore' (`getUserContactLinkByConnReq` cReq) >>= \case + withStore' (`getUserContactLinkByConnReq` cReqSchemas) >>= \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> do - let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHash) >>= \case + withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHashes) >>= \case Nothing -> pure $ CPContactAddress CAPOk Just (RcvDirectMsgConnection _conn Nothing) -> pure $ CPContactAddress CAPConnectingConfirmReconnect Just (RcvDirectMsgConnection _ (Just ct)) @@ -2255,12 +2261,11 @@ processChatCommand = \case Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" -- group link Just _ -> - withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReq) >>= \case + withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReqSchemas) >>= \case Just g -> pure $ CPGroupLink (GLPOwnLink g) Nothing -> do - let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHash - gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHash + connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHashes + gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHashes case (gInfo_, connEnt_) of (Nothing, Nothing) -> pure $ CPGroupLink GLPOk (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect @@ -2273,6 +2278,16 @@ processChatCommand = \case pure $ CPGroupLink (GLPConnectingProhibit gInfo_) | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) | otherwise -> pure $ CPGroupLink GLPOk + where + cReqSchemas :: (ConnReqContact, ConnReqContact) + cReqSchemas = case cReq of + (CRContactUri crData) -> + ( CRContactUri crData {crScheme = CRSSimplex}, + CRContactUri crData {crScheme = simplexChat} + ) + cReqHashes :: (ConnReqUriHash, ConnReqUriHash) + cReqHashes = bimap hash hash cReqSchemas + hash = ConnReqUriHash . C.sha256Hash . strEncode assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index d8ec38269f..59ffb57c6e 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -154,18 +154,18 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionEntityByConnReq :: DB.Connection -> User -> ConnReqInvitation -> IO (Maybe ConnectionEntity) -getConnectionEntityByConnReq db user cReq = do +getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq db user (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ - DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv = ? LIMIT 1" (Only cReq) + DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv IN (?,?) LIMIT 1" (cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ -- search connection for connection plan: -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address -getContactConnEntityByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe ConnectionEntity) -getContactConnEntityByConnReqHash db user cReqHash = do +getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash db user (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ DB.query db @@ -175,12 +175,12 @@ getContactConnEntityByConnReqHash db user cReqHash = do agent_conn_id, (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord FROM connections - WHERE via_contact_uri_hash = ? AND conn_status != ? + WHERE via_contact_uri_hash IN (?,?) AND conn_status != ? ORDER BY conn_ord DESC, created_at DESC LIMIT 1 ) |] - (cReqHash, ConnDeleted) + (cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 30e45a82dc..a4a19816da 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1121,21 +1121,21 @@ getGroupInfo db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) -getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> ConnReqContact -> IO (Maybe GroupInfo) -getGroupInfoByUserContactLinkConnReq db user cReq = do +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq db user (cReqSchema1, cReqSchema2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query db [sql| SELECT group_id FROM user_contact_links - WHERE conn_req_contact = ? + WHERE conn_req_contact IN (?,?) |] - (Only cReq) + (cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ -getGroupInfoByGroupLinkHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe GroupInfo) -getGroupInfoByGroupLinkHash db user@User {userId, userContactId} groupLinkHash = do +getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash db user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query db @@ -1143,11 +1143,11 @@ getGroupInfoByGroupLinkHash db user@User {userId, userContactId} groupLinkHash = SELECT g.group_id FROM groups g JOIN group_members mu ON mu.group_id = g.group_id - WHERE g.user_id = ? AND g.via_group_link_uri_hash = ? + WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) LIMIT 1 |] - (userId, groupLinkHash, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index fa573e4e60..c9c4806496 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -441,17 +441,17 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) -getUserContactLinkByConnReq :: DB.Connection -> ConnReqContact -> IO (Maybe UserContactLink) -getUserContactLinkByConnReq db cReq = +getUserContactLinkByConnReq :: DB.Connection -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) +getUserContactLinkByConnReq db (cReqSchema1, cReqSchema2) = maybeFirstRow toUserContactLink $ DB.query db [sql| SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links - WHERE conn_req_contact = ? + WHERE conn_req_contact IN (?,?) |] - (Only cReq) + (cReqSchema1, cReqSchema2) updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 677bc4c09d..1a133fd8e3 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -270,6 +270,10 @@ testPlanInvitationLinkOwn tmp = alice ##> ("/_connect plan 1 " <> inv) alice <## "invitation link: own link" + let invSchema2 = linkAnotherSchema inv + alice ##> ("/_connect plan 1 " <> invSchema2) + alice <## "invitation link: own link" + alice ##> ("/c " <> inv) alice <## "confirmation sent!" alice @@ -305,6 +309,10 @@ testPlanInvitationLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: connecting" + let invSchema2 = linkAnotherSchema inv + bob ##> ("/_connect plan 1 " <> invSchema2) + bob <## "invitation link: connecting" + testContactClear :: HasCallStack => FilePath -> IO () testContactClear = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 43498711e7..7a8b1368bf 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -2290,6 +2290,11 @@ testPlanGroupLinkOkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + bob ##> ("/c " <> gLink) bob <## "group link: known group #team" bob <## "use #team to send messages" @@ -2331,6 +2336,11 @@ testPlanHostContactDeletedGroupLinkKnown = bob <## "group link: known group #team" bob <## "use #team to send messages" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + bob ##> ("/c " <> gLink) bob <## "group link: known group #team" bob <## "use #team to send messages" @@ -2347,6 +2357,10 @@ testPlanGroupLinkOwn tmp = alice ##> ("/_connect plan 1 " <> gLink) alice <## "group link: own link for group #team" + let gLinkSchema2 = linkAnotherSchema gLink + alice ##> ("/_connect plan 1 " <> gLinkSchema2) + alice <## "group link: own link for group #team" + alice ##> ("/c " <> gLink) alice <## "connection request sent!" alice <## "alice_1 (Alice): accepting request to join group #team..." @@ -2373,6 +2387,9 @@ testPlanGroupLinkOwn tmp = alice ##> ("/_connect plan 1 " <> gLink) alice <## "group link: own link for group #team" + alice ##> ("/_connect plan 1 " <> gLinkSchema2) + alice <## "group link: own link for group #team" + -- group works if merged contact is deleted alice ##> "/d alice_1" alice <## "alice_1: contact is deleted" @@ -2405,6 +2422,10 @@ testPlanGroupLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting, allowed to reconnect" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting, allowed to reconnect" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice @@ -2417,6 +2438,10 @@ testPlanGroupLinkConnecting tmp = do bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting" + bob ##> ("/c " <> gLink) bob <## "group link: connecting" @@ -2462,6 +2487,10 @@ testPlanGroupLinkLeaveRejoin = bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: ok to connect" + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: ok to connect" + bob ##> ("/c " <> gLink) bob <## "connection request sent!" alice <## "bob_1 (Bob): accepting request to join group #team..." @@ -2490,6 +2519,10 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + bob ##> ("/c " <> gLink) bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 5682d09ee8..bd7f4c1682 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -599,6 +599,11 @@ testPlanAddressOkKnown = bob <## "contact address: known contact alice" bob <## "use @alice to send messages" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + bob ##> ("/c " <> cLink) bob <## "contact address: known contact alice" bob <## "use @alice to send messages" @@ -612,11 +617,15 @@ testPlanAddressOwn tmp = alice ##> ("/_connect plan 1 " <> cLink) alice <## "contact address: own address" + let cLinkSchema2 = linkAnotherSchema cLink + alice ##> ("/_connect plan 1 " <> cLinkSchema2) + alice <## "contact address: own address" + alice ##> ("/c " <> cLink) alice <## "connection request sent!" alice <## "alice_1 (Alice) wants to connect to you!" alice <## "to accept: /ac alice_1" - alice <## ("to reject: /rc alice_1 (the sender will NOT be notified)") + alice <## "to reject: /rc alice_1 (the sender will NOT be notified)" alice @@@ [("<@alice_1", ""), (":2","")] alice ##> "/ac alice_1" alice <## "alice_1 (Alice): accepting contact request..." @@ -658,6 +667,10 @@ testPlanAddressConnecting tmp = do bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: connecting, allowed to reconnect" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: connecting, allowed to reconnect" + threadDelay 100000 withTestChat tmp "alice" $ \alice -> do alice <## "Your address is active! To show: /sa" @@ -672,6 +685,10 @@ testPlanAddressConnecting tmp = do bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: connecting to contact alice" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: connecting to contact alice" + bob ##> ("/c " <> cLink) bob <## "contact address: connecting to contact alice" @@ -706,6 +723,10 @@ testPlanAddressContactDeletedReconnected = bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: ok to connect" + bob ##> ("/c " <> cLink) bob <## "connection request sent!" alice <## "bob (Bob) wants to connect to you!" @@ -726,6 +747,11 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + bob ##> ("/c " <> cLink) bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index d98431e815..fcb6b65e41 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -559,3 +559,11 @@ currentChatVRangeInfo = vRangeStr :: VersionRange -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" + +linkAnotherSchema :: String -> String +linkAnotherSchema link + | "https://simplex.chat/" `isPrefixOf` link = + T.unpack $ T.replace "https://simplex.chat/" "simplex:/" $ T.pack link + | "simplex:/" `isPrefixOf` link = + T.unpack $ T.replace "simplex:/" "https://simplex.chat/" $ T.pack link + | otherwise = error "link starts with neither https://simplex.chat/ nor simplex:/" From e4c8386f3ffc868afc210d432dd5a97216f2fe7a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Oct 2023 19:23:38 +0400 Subject: [PATCH 36/80] core: replace simplex:/ with simplex.chat links in view; remove trustedUri flag from simplex links markdown format (#3235) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Markdown.hs | 11 ++++------- src/Simplex/Chat/View.hs | 14 ++++++++++---- stack.yaml | 2 +- tests/ChatTests/Profiles.hs | 1 - tests/MarkdownTests.hs | 14 +++++++------- tests/ProtocolTests.hs | 22 +++++++++++----------- 9 files changed, 36 insertions(+), 34 deletions(-) diff --git a/cabal.project b/cabal.project index 3da442ac49..9a9a3e25da 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 919550948501d315aa8845cbed1781d4298d4ced + tag: 1ad69cf74f18f25713ce564e1629d2538313b9e0 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0a199779d0..17d650cb09 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."919550948501d315aa8845cbed1781d4298d4ced" = "05d0cadhlazqi2lxcb7nvyjrf8q49c6ax7b8rahawbh1zmwg38nm"; + "https://github.com/simplex-chat/simplexmq.git"."1ad69cf74f18f25713ce564e1629d2538313b9e0" = "1kil0962pn3ksnxh7dcwcbnkidz95yl31rm4m585ps7wnh6fp0l9"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e224f04ebb..89f64189af 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5936,7 +5936,7 @@ chatCommandP = adminContactReq :: ConnReqContact adminContactReq = - either error id $ strDecode "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" + either error id $ strDecode "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" timeItToView :: ChatMonad' m => String -> m a -> m a timeItToView s action = do diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index d18f28db31..30990f225d 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -32,7 +32,7 @@ import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) -import Simplex.Messaging.Protocol (ProtocolServer (..), SrvLoc (..)) +import Simplex.Messaging.Protocol (ProtocolServer (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email @@ -48,7 +48,7 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, trustedUri :: Bool, smpHosts :: NonEmpty Text} + | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} | Email | Phone deriving (Eq, Show, Generic) @@ -229,15 +229,12 @@ markdownP = mconcat <$> A.many' fragmentP simplexUriFormat = \case ACR _ (CRContactUri crData) -> let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex} - in SimplexLink (linkType' crData) uri (trustedUri' crData) $ uriHosts crData + in SimplexLink (linkType' crData) uri $ uriHosts crData ACR _ (CRInvitationUri crData e2e) -> let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e - in SimplexLink XLInvitation uri (trustedUri' crData) $ uriHosts crData + in SimplexLink XLInvitation uri $ uriHosts crData where uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues - trustedUri' ConnReqUriData {crScheme} = case crScheme of - CRSSimplex -> True - CRSAppServer (SrvLoc host _) -> host == "simplex.chat" linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of Just (CRDataGroup _) -> XLGroup Nothing -> XLContact diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index fa2213d083..07cbec601f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -693,11 +693,14 @@ viewConnReqInvitation :: ConnReqInvitation -> [StyledString] viewConnReqInvitation cReq = [ "pass this invitation link to your contact (via another channel): ", "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatInvitation cReq), "", "and ask them to connect: " <> highlight' "/c " ] +simplexChatInvitation :: ConnReqInvitation -> ConnReqInvitation +simplexChatInvitation (CRInvitationUri crData e2e) = CRInvitationUri crData {crScheme = simplexChat} e2e + viewContactNotFound :: ContactName -> Maybe (GroupInfo, GroupMember) -> [StyledString] viewContactNotFound cName suspectedMember = ["no contact " <> ttyContact cName <> useMessageMember] @@ -736,7 +739,7 @@ connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] connReqContact_ intro cReq = [ intro, "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatContact cReq), "", "Anybody can send you contact requests with: " <> highlight' "/c ", "to show it again: " <> highlight' "/sa", @@ -744,6 +747,9 @@ connReqContact_ intro cReq = "to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)" ] +simplexChatContact :: ConnReqContact -> ConnReqContact +simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simplexChat} + autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ = \case Just AutoAccept {acceptIncognito, autoReply} -> @@ -755,7 +761,7 @@ groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> groupLink_ intro g cReq mRole = [ intro, "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatContact cReq), "", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c ", "to show it again: " <> highlight ("/show link #" <> viewGroupName g), @@ -1022,7 +1028,7 @@ viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) l]) contactLink + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink <> maybe ["you've shared main profile with this contact"] (\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p]) diff --git a/stack.yaml b/stack.yaml index 5d9fc214fb..6e047f7e6c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 919550948501d315aa8845cbed1781d4298d4ced + commit: 1ad69cf74f18f25713ce564e1629d2538313b9e0 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index bd7f4c1682..68b925342e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -747,7 +747,6 @@ testPlanAddressContactDeletedReconnected = bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" - let cLinkSchema2 = linkAnotherSchema cLink bob ##> ("/_connect plan 1 " <> cLinkSchema2) bob <## "contact address: known contact alice_1" bob <## "use @alice_1 to send messages" diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 837849d7e4..83a180c745 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -137,8 +137,8 @@ textColor = describe "text color (red)" do uri :: Text -> Markdown uri = Markdown $ Just Uri -simplexLink :: SimplexLinkType -> Text -> Bool -> NonEmpty Text -> Text -> Markdown -simplexLink linkType simplexUri trustedUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, trustedUri, smpHosts} +simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown +simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, smpHosts} textWithUri :: Spec textWithUri = describe "text with Uri" do @@ -152,13 +152,13 @@ textWithUri = describe "text with Uri" do parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" - parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("https://simplex.chat" <> inv) - parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("simplex:" <> inv) - parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) False ["smp.simplex.im"] ("https://example.com" <> inv) + parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) + parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) + parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) let ct = "/contact#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" - parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) True ["smp.simplex.im"] ("https://simplex.chat" <> ct) + parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) let gr = "/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" - parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) True ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) email :: Text -> Markdown email = Markdown $ Just Email diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d62d7a470a..0b99c5a4d8 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -39,7 +39,7 @@ queue = connReqData :: ConnReqUriData connReqData = ConnReqUriData - { crScheme = simplexChat, + { crScheme = CRSSimplex, crAgentVRange = mkVersionRange 1 1, crSmpQueues = [queue], crClientData = Nothing @@ -184,7 +184,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -220,10 +220,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -241,16 +241,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -271,10 +271,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") it "x.grp.direct.inv without content" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing it "x.info.probe" $ "{\"v\":\"1\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" From 99c458406f55683f408b27223e8a01263035b25e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 17 Oct 2023 11:44:35 +0400 Subject: [PATCH 37/80] android: remove browser mode for simplex links (#3239) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- .../chat/simplex/common/views/chat/item/TextItemView.kt | 8 ++------ .../simplex/common/views/usersettings/PrivacySettings.kt | 7 +++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a4b76cc79d..f3655d0008 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2416,7 +2416,7 @@ sealed class Format { @Serializable @SerialName("secret") class Secret: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List): Format() + @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index eabab138ba..ff1267d0fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -136,12 +136,8 @@ fun MarkdownText ( val link = ft.link(linkMode) if (link != null) { hasLinks = true - val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) { - SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline) - } else { - ft.format.style - } - withAnnotation(tag = if (ft.format is Format.SimplexLink && linkMode != SimplexLinkMode.BROWSER) "SIMPLEX_URL" else "URL", annotation = link) { + val ftStyle = ft.format.style + withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) { withStyle(ftStyle) { append(ft.viewText(linkMode)) } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 210d3c6136..211fe59671 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -92,9 +92,6 @@ fun PrivacySettingsView( chatModel.simplexLinkMode.value = it }) } - if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) { - SectionTextFooter(stringResource(MR.strings.simplex_link_mode_browser_warning)) - } SectionDividerSpaced() val currentUser = chatModel.currentUser.value @@ -185,8 +182,10 @@ fun PrivacySettingsView( @Composable private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { + val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL) + val pickerValues = modeValues + if (modeValues.contains(simplexLinkModeState.value)) emptyList() else listOf(simplexLinkModeState.value) val values = remember { - SimplexLinkMode.values().map { + pickerValues.map { when (it) { SimplexLinkMode.DESCRIPTION -> it to generalGetString(MR.strings.simplex_link_mode_description) SimplexLinkMode.FULL -> it to generalGetString(MR.strings.simplex_link_mode_full) From d8d47d706d50fa3f18d1b5ac8a736deb63b05b10 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 17 Oct 2023 12:56:12 +0400 Subject: [PATCH 38/80] ios: connection plan improvements; remove browser mode for simplex links (#3237) --- apps/ios/Shared/ContentView.swift | 5 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 4 +- .../Views/Chat/ChatItem/MsgContentView.swift | 6 +- .../Views/Chat/Group/GroupLinkView.swift | 4 +- .../Chat/Group/GroupMemberInfoView.swift | 4 +- .../ChatList/ContactConnectionInfo.swift | 2 +- .../Shared/Views/NewChat/AddContactView.swift | 4 +- .../Shared/Views/NewChat/NewChatButton.swift | 56 ++++++++++++++++--- apps/ios/Shared/Views/NewChat/QRCode.swift | 16 ++++++ .../Onboarding/CreateSimpleXAddress.swift | 4 +- .../Views/UserSettings/PrivacySettings.swift | 8 +-- .../Views/UserSettings/SettingsView.swift | 2 +- .../Views/UserSettings/UserAddressView.swift | 4 +- apps/ios/SimpleXChat/APITypes.swift | 6 +- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- 15 files changed, 88 insertions(+), 39 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 04eadac2c1..b69ccbb7c7 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -292,10 +292,7 @@ struct ContentView: View { var path = url.path if (path == "/contact" || path == "/invitation") { path.removeFirst() - // TODO normalize in backend; revert - // let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - var link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - link = link.starts(with: "simplex:/") ? link.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") : link + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") planAndConnect( link, showAlert: showPlanAndConnectAlert, diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 81412bf310..5438eb13bc 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -168,9 +168,9 @@ struct ChatInfoView: View { if let contactLink = contact.contactLink { Section { - QRCode(uri: contactLink) + SimpleXLinkQRCode(uri: contactLink) Button { - showShareSheet(items: [contactLink]) + showShareSheet(items: [simplexChatLink(contactLink)]) } label: { Label("Share address", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 498b3cb2e0..8b757ed1a3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -121,13 +121,11 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text { case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary) case let .colored(color): return Text(t).foregroundColor(color.uiColor) case .uri: return linkText(t, t, preview, prefix: "") - case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts): + case let .simplexLink(linkType, simplexUri, smpHosts): switch privacySimplexLinkModeDefault.get() { case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") case .full: return linkText(t, simplexUri, preview, prefix: "") - case .browser: return trustedUri - ? linkText(t, t, preview, prefix: "") - : linkText(t, t, preview, prefix: "", color: .red, uiColor: .red) + case .browser: return linkText(t, simplexUri, preview, prefix: "") } case .email: return linkText(t, t, preview, prefix: "mailto:") case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 3731e0c4d7..781870bf5e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -41,9 +41,9 @@ struct GroupLinkView: View { } } .frame(height: 36) - QRCode(uri: groupLink) + SimpleXLinkQRCode(uri: groupLink) Button { - showShareSheet(items: [groupLink]) + showShareSheet(items: [simplexChatLink(groupLink)]) } label: { Label("Share link", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 3d10101dee..4b64458145 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -94,9 +94,9 @@ struct GroupMemberInfoView: View { if let contactLink = member.contactLink { Section { - QRCode(uri: contactLink) + SimpleXLinkQRCode(uri: contactLink) Button { - showShareSheet(items: [contactLink]) + showShareSheet(items: [simplexChatLink(contactLink)]) } label: { Label("Share address", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 3e42d2f207..7c973c73c6 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -61,7 +61,7 @@ struct ContactConnectionInfo: View { if contactConnection.initiated, let connReqInv = contactConnection.connReqInv { - QRCode(uri: connReqInv) + SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) incognitoEnabled() shareLinkButton(connReqInv) oneTimeLinkLearnMoreButton() diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 31b6b64f32..344a8d1f96 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -21,7 +21,7 @@ struct AddContactView: View { List { Section { if connReqInvitation != "" { - QRCode(uri: connReqInvitation) + SimpleXLinkQRCode(uri: connReqInvitation) } else { ProgressView() .progressViewStyle(.circular) @@ -99,7 +99,7 @@ func sharedProfileInfo(_ incognito: Bool) -> Text { func shareLinkButton(_ connReqInvitation: String) -> some View { Button { - showShareSheet(items: [connReqInvitation]) + showShareSheet(items: [simplexChatLink(connReqInvitation)]) } label: { settingsRow("square.and.arrow.up") { Text("Share 1-time link") diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index e4fbd8bd47..8d095e907b 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -62,7 +62,9 @@ enum PlanAndConnectAlert: Identifiable { case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case invitationLinkConnecting(connectionLink: String) case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) var id: String { @@ -70,7 +72,9 @@ enum PlanAndConnectAlert: Identifiable { case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" + case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)" case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" + case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)" case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" } } @@ -103,6 +107,16 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { ), secondaryButton: .cancel() ) + case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Repeat connection request?"), + message: Text("You have already requested connection via this address!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito): return Alert( title: Text("Join group?"), @@ -113,6 +127,16 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { ), secondaryButton: .cancel() ) + case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Repeat join request?"), + message: Text("You are already joining the group via this link!"), + primaryButton: .destructive( + Text(incognito ? "Join incognito" : "Join"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) case let .groupLinkConnecting(_, groupInfo): if let groupInfo = groupInfo { return Alert( @@ -130,13 +154,13 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { enum PlanAndConnectActionSheet: Identifiable { case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) - case ownLinkAskCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) var id: String { switch self { case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" - case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, _, _): return "ownLinkAskCurrentOrIncognitoProfile \(connectionLink)" + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" } } @@ -153,7 +177,7 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool .cancel() ] ) - case let .ownLinkAskCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title): return ActionSheet( title: Text(title), buttons: [ @@ -211,7 +235,7 @@ func planAndConnect( if let incognito = incognito { showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) } else { - showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) } case let .connecting(contact_): logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") @@ -238,10 +262,17 @@ func planAndConnect( if let incognito = incognito { showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) } else { - showActionSheet(.ownLinkAskCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) } - case let .connecting(contact): - logger.debug("planAndConnect, .contactAddress, .connecting, incognito=\(incognito?.description ?? "nil")") + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) + } + case let .connectingProhibit(contact): + logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") @@ -258,8 +289,15 @@ func planAndConnect( case let .ownLink(groupInfo): logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) - case let .connecting(groupInfo_): - logger.debug("planAndConnect, .groupLink, .connecting, incognito=\(incognito?.description ?? "nil")") + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) + } + case let .connectingProhibit(groupInfo_): + logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) case let .known(groupInfo): logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index 0785826fa9..4bec3f8e66 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -28,6 +28,22 @@ struct MutableQRCode: View { } } +struct SimpleXLinkQRCode: View { + let uri: String + var withLogo: Bool = true + var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) + + var body: some View { + QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor) + } +} + +func simplexChatLink(_ uri: String) -> String { + uri.starts(with: "simplex:/") + ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") + : uri +} + struct QRCode: View { let uri: String var withLogo: Bool = true diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 049c4bee08..249180686c 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View { Spacer() if let userAddress = m.userAddress { - QRCode(uri: userAddress.connReqContact) + SimpleXLinkQRCode(uri: userAddress.connReqContact) .frame(maxHeight: g.size.width) shareQRCodeButton(userAddress) .frame(maxWidth: .infinity) @@ -126,7 +126,7 @@ struct CreateSimpleXAddress: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [userAddress.connReqContact]) + showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) } label: { Label("Share", systemImage: "square.and.arrow.up") } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 16555fad3a..71ff7b88bf 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -93,7 +93,9 @@ struct PrivacySettings: View { } settingsRow("link") { Picker("SimpleX links", selection: $simplexLinkMode) { - ForEach(SimpleXLinkMode.values) { mode in + ForEach( + SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode]) + ) { mode in Text(mode.text) } } @@ -104,10 +106,6 @@ struct PrivacySettings: View { } } header: { Text("Chats") - } footer: { - if case .browser = simplexLinkMode { - Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.") - } } Section { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f076a4eb7d..1cc859f49e 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -93,7 +93,7 @@ enum SimpleXLinkMode: String, Identifiable { case full case browser - static var values: [SimpleXLinkMode] = [.description, .full, .browser] + static var values: [SimpleXLinkMode] = [.description, .full] public var id: Self { self } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 86bf0048b8..60e5cc7b04 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -190,7 +190,7 @@ struct UserAddressView: View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { Section { - MutableQRCode(uri: Binding.constant(userAddress.connReqContact)) + MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact))) shareQRCodeButton(userAddress) if MFMailComposeViewController.canSendMail() { shareViaEmailButton(userAddress) @@ -248,7 +248,7 @@ struct UserAddressView: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [userAddress.connReqContact]) + showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) } label: { settingsRow("square.and.arrow.up") { Text("Share address") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ddab4c73bd..53da91b042 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -886,14 +886,16 @@ public enum InvitationLinkPlan: Decodable { public enum ContactAddressPlan: Decodable { case ok case ownLink - case connecting(contact: Contact) + case connectingConfirmReconnect + case connectingProhibit(contact: Contact) case known(contact: Contact) } public enum GroupLinkPlan: Decodable { case ok case ownLink(groupInfo: GroupInfo) - case connecting(groupInfo_: GroupInfo?) + case connectingConfirmReconnect + case connectingProhibit(groupInfo_: GroupInfo?) case known(groupInfo: GroupInfo) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index caaccb5454..c31786c700 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3068,7 +3068,7 @@ public enum Format: Decodable, Equatable { case secret case colored(color: FormatColor) case uri - case simplexLink(linkType: SimplexLinkType, simplexUri: String, trustedUri: Bool, smpHosts: [String]) + case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) case email case phone } From 29c8ab7c9b964a6a227560ec4a928ed3c6e15fa4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:05:16 +0100 Subject: [PATCH 39/80] ui: file & media preference for groups (#3243) --- .../Views/Chat/Group/GroupPreferencesView.swift | 3 +-- .../common/views/chat/group/GroupPreferences.kt | 11 +++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index af1a778ad1..860a6febb0 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -27,8 +27,7 @@ struct GroupPreferencesView: View { featureSection(.directMessages, $preferences.directMessages.enable) featureSection(.reactions, $preferences.reactions.enable) featureSection(.voice, $preferences.voice.enable) -// TODO uncomment in 5.3 -// featureSection(.files, $preferences.files.enable) + featureSection(.files, $preferences.files.enable) if groupInfo.canEdit { Section { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index d95d2a4cff..4571a38c13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -103,12 +103,11 @@ private fun GroupPreferencesLayout( FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) { applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) } -// TODO uncomment in 5.3 -// SectionDividerSpaced(true, maxBottomPadding = false) -// val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } -// FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { -// applyPrefs(preferences.copy(files = GroupPreference(enable = it))) -// } + SectionDividerSpaced(true, maxBottomPadding = false) + val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } + FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { + applyPrefs(preferences.copy(files = GroupPreference(enable = it))) + } if (groupInfo.canEdit) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) ResetSaveButtons( From a02886ca5d46a444d7edd4645a415b2c16b8dd9d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 18 Oct 2023 10:19:24 +0100 Subject: [PATCH 40/80] core: fix editing and status changes removing reactions from view (#3245) * core: fix editing and status changes removing reactions from view * refactor * refactor 2 * case --- src/Simplex/Chat.hs | 19 ++++--- src/Simplex/Chat/Store/Messages.hs | 80 ++++++++++++++++-------------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 89f64189af..82499baee6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -760,8 +760,9 @@ processChatCommand = \case unzipMaybe3 _ = (Nothing, Nothing, Nothing) APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of CTDirect -> do - (ct@Contact {contactId}, cci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + ct@Contact {contactId} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ + cci <- withStore $ \db -> getDirectCIWithReactions db user ct itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of @@ -783,7 +784,7 @@ processChatCommand = \case CTGroup -> do Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db user chatId assertUserGroupRole gInfo GRAuthor - cci <- withStore $ \db -> getGroupChatItem db user chatId itemId + cci <- withStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of @@ -2390,8 +2391,8 @@ updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do forM_ aciContent_ $ \aciContent -> updateDirectChatItemView user ct chatItemId aciContent False msgId_ updateDirectChatItemView :: ChatMonad m => User -> Contact -> ChatItemId -> ACIContent -> Bool -> Maybe MessageId -> m () -updateDirectChatItemView user ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) live msgId_ = do - ci' <- withStore $ \db -> updateDirectChatItem db user contactId chatItemId ciContent live msgId_ +updateDirectChatItemView user ct chatItemId (ACIContent msgDir ciContent) live msgId_ = do + ci' <- withStore $ \db -> updateDirectChatItem db user ct chatItemId ciContent live msgId_ toView $ CRChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') callStatusItemContent :: ChatMonad m => User -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) @@ -3996,7 +3997,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci' <- withStore' $ \db -> do when changed $ addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateDirectChatItem' db user contactId ci content live $ Just msgId + reactions <- getDirectCIReactions db ct sharedMsgId + updateDirectChatItem' db user contactId ci {reactions} content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) @@ -4134,7 +4136,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci' <- withStore' $ \db -> do when changed $ addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateGroupChatItem db user groupId ci content live $ Just msgId + reactions <- getGroupCIReactions db gInfo memberId sharedMsgId + updateGroupChatItem db user groupId ci {reactions} content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) @@ -4939,7 +4942,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) | itemStatus == newStatus -> pure () | otherwise -> do - chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId itemId newStatus + chatItem <- withStore $ \db -> updateDirectChatItemStatus db user ct itemId newStatus toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) _ -> pure () @@ -4962,7 +4965,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do memStatusCounts <- withStore' (`getGroupSndStatusCounts` itemId) let newStatus = membersGroupItemStatus memStatusCounts when (newStatus /= itemStatus) $ do - chatItem <- withStore $ \db -> updateGroupChatItemStatus db user groupId itemId newStatus + chatItem <- withStore $ \db -> updateGroupChatItemStatus db user gInfo itemId newStatus toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 0f9abaa465..35a8bad698 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -15,7 +15,6 @@ module Simplex.Chat.Store.Messages ( getContactConnIds_, - getDirectChatReactions_, -- * Message and chat item functions deleteContactCIs, @@ -68,9 +67,11 @@ module Simplex.Chat.Store.Messages setGroupReaction, getChatItemIdByAgentMsgId, getDirectChatItem, + getDirectCIWithReactions, getDirectChatItemBySharedMsgId, getDirectChatItemByAgentMsgId, getGroupChatItem, + getGroupCIWithReactions, getGroupChatItemBySharedMsgId, getGroupMemberCIBySharedMsgId, getGroupChatItemByAgentMsgId, @@ -755,7 +756,7 @@ getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String getGroupChat db user groupId pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db user groupId - liftIO . getGroupChatReactions_ db g =<< case pagination of + case pagination of CPLast count -> getGroupChatLast_ db user g count search CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search @@ -764,7 +765,7 @@ getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> Exce getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] @@ -802,7 +803,7 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] @@ -825,7 +826,7 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] @@ -1149,23 +1150,24 @@ getChatItemIdByAgentMsgId db connId msgId = |] (connId, msgId) -updateDirectChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTDirect d) -updateDirectChatItemStatus db user@User {userId} contactId itemId itemStatus = do - ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId +updateDirectChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItemStatus db user@User {userId} ct@Contact {contactId} itemId itemStatus = do + ci <- liftEither . correctDir =<< getDirectCIWithReactions db user ct itemId currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, contactId, itemId) pure ci {meta = (meta ci) {itemStatus}} - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) -updateDirectChatItem db user contactId itemId newContent live msgId_ = do - ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId +updateDirectChatItem :: MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItem db user ct@Contact {contactId} itemId newContent live msgId_ = do + ci <- liftEither . correctDir =<< getDirectCIWithReactions db user ct itemId liftIO $ updateDirectChatItem' db user contactId ci newContent live msgId_ - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci + +getDirectCIWithReactions :: DB.Connection -> User -> Contact -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) +getDirectCIWithReactions db user ct@Contact {contactId} itemId = + liftIO . directCIWithReactions db ct =<< getDirectChatItem db user contactId itemId + +correctDir :: MsgDirectionI d => CChatItem c -> Either StoreError (ChatItem c d) +correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci updateDirectChatItem' :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTDirect d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTDirect d) updateDirectChatItem' db User {userId} contactId ci newContent live msgId_ = do @@ -1303,7 +1305,7 @@ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = getDirectChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItem db User {userId} contactId itemId = ExceptT $ do currentTs <- getCurrentTime - join <$> firstRow (toDirectChatItem currentTs) (SEChatItemNotFound itemId) getItem + firstRow' (toDirectChatItem currentTs) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -1351,17 +1353,26 @@ getDirectChatItemIdByText' db User {userId} contactId msg = |] (userId, contactId, msg <> "%") -updateGroupChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupId -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d) -updateGroupChatItemStatus db user@User {userId} groupId itemId itemStatus = do - ci <- liftEither . correctDir =<< getGroupChatItem db user groupId itemId +updateGroupChatItemStatus :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d) +updateGroupChatItemStatus db user@User {userId} g@GroupInfo {groupId} itemId itemStatus = do + ci <- liftEither . correctDir =<< getGroupCIWithReactions db user g itemId currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, groupId, itemId) pure ci {meta = (meta ci) {itemStatus}} - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateGroupChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) +getGroupCIWithReactions :: DB.Connection -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupCIWithReactions db user g@GroupInfo {groupId} itemId = do + liftIO . groupCIWithReactions db g =<< getGroupChatItem db user groupId itemId + +groupCIWithReactions :: DB.Connection -> GroupInfo -> CChatItem 'CTGroup -> IO (CChatItem 'CTGroup) +groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of + Just sharedMsgId -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- getGroupCIReactions db g memberId sharedMsgId + pure $ CChatItem md ci {reactions} + Nothing -> pure cci + +updateGroupChatItem :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) updateGroupChatItem db user groupId ci newContent live msgId_ = do currentTs <- liftIO getCurrentTime let ci' = updatedChatItem ci newContent live currentTs @@ -1370,7 +1381,7 @@ updateGroupChatItem db user groupId ci newContent live msgId_ = do -- this function assumes that the group item with correct chat direction already exists, -- it should be checked before calling it -updateGroupChatItem_ :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> Maybe MessageId -> IO () +updateGroupChatItem_ :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> Maybe MessageId -> IO () updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = do let CIMeta {itemId, itemText, itemStatus, itemDeleted, itemEdited, itemTimed, itemLive, updatedAt} = meta itemDeleted' = isJust itemDeleted @@ -1501,7 +1512,7 @@ getGroupChatItemByAgentMsgId db user groupId connId msgId = do getGroupChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do currentTs <- getCurrentTime - join <$> firstRow (toGroupChatItem currentTs userContactId) (SEChatItemNotFound itemId) getItem + firstRow' (toGroupChatItem currentTs userContactId) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -1671,18 +1682,15 @@ getChatItemVersions db itemId = do getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect) getDirectChatReactions_ db ct c@Chat {chatItems} = do - chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do - reactions <- maybe (pure []) (getDirectCIReactions db ct) itemSharedMsgId - pure $ CChatItem md ci {reactions} + chatItems' <- mapM (directCIWithReactions db ct) chatItems pure c {chatItems = chatItems'} -getGroupChatReactions_ :: DB.Connection -> GroupInfo -> Chat 'CTGroup -> IO (Chat 'CTGroup) -getGroupChatReactions_ db g c@Chat {chatItems} = do - chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do - let GroupMember {memberId} = chatItemMember g ci - reactions <- maybe (pure []) (getGroupCIReactions db g memberId) itemSharedMsgId +directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) +directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of + Just sharedMsgId -> do + reactions <- getDirectCIReactions db ct sharedMsgId pure $ CChatItem md ci {reactions} - pure c {chatItems = chatItems'} + Nothing -> pure cci getDirectCIReactions :: DB.Connection -> Contact -> SharedMsgId -> IO [CIReactionCount] getDirectCIReactions db Contact {contactId} itemSharedMsgId = From 706d6bf65b8affea286d5727a34f0500647edc21 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 18 Oct 2023 11:23:35 +0100 Subject: [PATCH 41/80] core, ui: prevent old sent items re-added to chat, and "new" status overriding "sent" (#3246) * core, ui: prevent old sent items re-added to chat, and "new" status overriding "sent" * clear item statuses when changing current chat * remove iOS hack * remote state/published from chatItemStatuses --- apps/ios/Shared/Model/ChatModel.swift | 18 ++++++++++-------- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +++----- apps/ios/Shared/Views/Chat/ChatView.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 14 ++++++++++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 7 ++----- .../views/chat/group/GroupMemberInfoView.kt | 1 + .../views/chatlist/ChatListNavLinkView.kt | 1 + .../common/views/newchat/AddGroupView.kt | 1 + src/Simplex/Chat.hs | 8 +++----- 9 files changed, 34 insertions(+), 25 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index b1a83dd5e6..3cc52d502d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -62,6 +62,7 @@ final class ChatModel: ObservableObject { // current chat @Published var chatId: String? @Published var reversedChatItems: [ChatItem] = [] + var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GroupMember] = [] // items in the terminal view @@ -306,7 +307,11 @@ final class ChatModel: ObservableObject { return false } else { withAnimation(itemAnimation()) { - reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + var ci = cItem + if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { + ci.meta.itemStatus = status + } + reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) } return true } @@ -319,23 +324,19 @@ final class ChatModel: ObservableObject { } } - func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) { withAnimation { _updateChatItem(at: i, with: cItem) } + } else if let status = status { + chatItemStatuses.updateValue(status, forKey: cItem.id) } } private func _updateChatItem(at i: Int, with cItem: ChatItem) { - let ci = reversedChatItems[i] reversedChatItems[i] = cItem reversedChatItems[i].viewTimestamp = .now - // on some occasions the confirmation of message being accepted by the server (tick) - // arrives earlier than the response from API, and item remains without tick - if case .sndNew = cItem.meta.itemStatus { - reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus - } } private func getChatItemIndex(_ cItem: ChatItem) -> Int? { @@ -474,6 +475,7 @@ final class ChatModel: ObservableObject { } // clear current chat if chatId == cInfo.id { + chatItemStatuses = [:] reversedChatItems = [] } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2e724bcd83..6fe1bef8ee 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -312,6 +312,7 @@ func loadChat(chat: Chat, search: String = "") { do { let cInfo = chat.chatInfo let m = ChatModel.shared + m.chatItemStatuses = [:] m.reversedChatItems = [] let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) m.updateChatInfo(chat.chatInfo) @@ -1421,11 +1422,8 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .chatItemStatusUpdated(user, aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if !cItem.isDeletedContent { - let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true - if added && cItem.showNotification { - NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) - } + if !cItem.isDeletedContent && active(user) { + await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) } } if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 81473709cb..21af0ebe17 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -91,6 +91,7 @@ struct ChatView: View { chatModel.chatId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { + chatModel.chatItemStatuses = [:] chatModel.reversedChatItems = [] } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index f3655d0008..132384219e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -53,6 +53,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) val chatItems = mutableStateListOf() + val chatItemStatuses = mutableMapOf() val groupMembers = mutableStateListOf() val terminalItems = mutableStateListOf() @@ -272,7 +273,13 @@ object ChatModel { Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") false } else { - chatItems.add(cItem) + val status = chatItemStatuses.remove(cItem.id) + val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { + cItem.copy(meta = cItem.meta.copy(itemStatus = status)) + } else { + cItem + } + chatItems.add(ci) Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } @@ -282,13 +289,15 @@ object ChatModel { } } - suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) { + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { withContext(Dispatchers.Main) { if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem } + } else if (status != null) { + chatItemStatuses[cItem.id] = status } } } @@ -326,6 +335,7 @@ object ChatModel { } // clear current chat if (chatId.value == cInfo.id) { + chatItemStatuses.clear() chatItems.clear() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index cc8d481b59..6bd269f24d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1489,11 +1489,8 @@ object ChatController { is CR.ChatItemStatusUpdated -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem - if (!cItem.isDeletedContent) { - val added = if (active(r.user)) chatModel.upsertChatItem(cInfo, cItem) else true - if (added && cItem.showNotification) { - ntfManager.notifyMessageReceived(r.user, cInfo, cItem) - } + if (!cItem.isDeletedContent && active(r.user)) { + chatModel.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } } is CR.ChatItemUpdated -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e14089ec52..c76fb73a6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -73,6 +73,7 @@ fun GroupMemberInfoView( chatModel.addChat(c) } chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(c.chatItems) chatModel.chatId.value = c.id closeAll() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 566c981811..b45543df4e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -141,6 +141,7 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { suspend fun openChat(chat: Chat, chatModel: ChatModel) { chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 6ad919c27d..be446f6087 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -41,6 +41,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { if (groupInfo != null) { chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id setGroupMembers(groupInfo, chatModel) close.invoke() diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 82499baee6..7e2c2b71e7 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -584,11 +584,11 @@ processChatCommand = \case timed_ <- sndContactCITimed live ct itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) + ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live case ft_ of Just ft@FileTransferMeta {fileInline = Just IFMSent} -> sendDirectFileInline ct ft sharedMsgId _ -> pure () - ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) @@ -649,11 +649,11 @@ processChatCommand = \case timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) - mapM_ (sendGroupFileInline ms sharedMsgId) ft_ ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live withStore' $ \db -> forM_ sentToMembers $ \GroupMember {groupMemberId} -> createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew + mapM_ (sendGroupFileInline ms sharedMsgId) ft_ forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) @@ -5208,9 +5208,7 @@ deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgFlags msgBody let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - withStoreCtx' - (Just $ "createSndMsgDelivery, sndMsgDelivery: " <> show sndMsgDelivery <> ", msgId: " <> show msgId <> ", cmEventTag: " <> show cmEventTag <> ", msgDeliveryStatus: MDSSndAgent") - $ \db -> createSndMsgDelivery db sndMsgDelivery msgId + withStore' $ \db -> createSndMsgDelivery db sndMsgDelivery msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = From 852e77b1d9ad07ea31f932d83386eb34469f7377 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:58:33 +0400 Subject: [PATCH 42/80] android: connect plan (#3242) --- apps/ios/Shared/Model/SimpleXAPI.swift | 1 - .../Onboarding/CreateSimpleXAddress.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 1 + .../chat/simplex/common/model/SimpleXAPI.kt | 49 +++ .../simplex/common/views/chat/ChatInfoView.kt | 6 +- .../common/views/chat/group/GroupLinkView.kt | 10 +- .../views/chat/group/GroupMemberInfoView.kt | 50 +-- .../views/chatlist/ChatListNavLinkView.kt | 7 + .../common/views/chatlist/ChatListView.kt | 42 +- .../common/views/newchat/AddContactView.kt | 12 +- .../newchat/ContactConnectionInfoView.kt | 4 +- .../common/views/newchat/PasteToConnect.kt | 23 +- .../simplex/common/views/newchat/QRCode.kt | 23 + .../common/views/newchat/ScanToConnectView.kt | 412 ++++++++++++++++-- .../views/onboarding/CreateSimpleXAddress.kt | 9 +- .../views/usersettings/UserAddressView.kt | 8 +- .../commonMain/resources/MR/base/strings.xml | 29 +- 17 files changed, 512 insertions(+), 176 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6fe1bef8ee..bad15ad52f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -594,7 +594,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P } func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { - logger.error("apiConnectPlan connReq: \(connReq)") let userId = try currentUserId("apiConnectPlan") let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 249180686c..935f09cc1b 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -194,7 +194,7 @@ struct SendAddressMailView: View { let messageBody = String(format: NSLocalizedString("""

Hi!

Connect to me via SimpleX Chat

- """, comment: "email text"), userAddress.connReqContact) + """, comment: "email text"), simplexChatLink(userAddress.connReqContact)) MailView( isShowing: self.$showMailView, result: $mailViewResult, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 132384219e..ad033387ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -136,6 +136,7 @@ object ChatModel { fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } + fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } fun addChat(chat: Chat) = chats.add(index = 0, chat) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 6bd269f24d..8397d2edb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -855,6 +855,14 @@ object ChatController { return null } + suspend fun apiConnectPlan(connReq: String): ConnectionPlan? { + val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } + val r = sendCmd(CC.APIConnectPlan(userId, connReq)) + if (r is CR.CRConnectionPlan) return r.connectionPlan + Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") @@ -1914,6 +1922,7 @@ sealed class CC { class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() class APIAddContact(val userId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() + class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() @@ -2023,6 +2032,7 @@ sealed class CC { is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" + is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" @@ -2124,6 +2134,7 @@ sealed class CC { is APIVerifyGroupMember -> "apiVerifyGroupMember" is APIAddContact -> "apiAddContact" is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" + is APIConnectPlan -> "apiConnectPlan" is APIConnect -> "apiConnect" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" @@ -3337,6 +3348,7 @@ sealed class CR { @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() + @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @@ -3472,6 +3484,7 @@ sealed class CR { is ConnectionVerified -> "connectionVerified" is Invitation -> "invitation" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" + is CRConnectionPlan -> "connectionPlan" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" is ContactAlreadyExists -> "contactAlreadyExists" @@ -3602,6 +3615,7 @@ sealed class CR { is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is Invitation -> withUser(user, connReqInvitation) is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) + is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is SentConfirmation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, noDetails()) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) @@ -3715,6 +3729,39 @@ fun chatError(r: CR): ChatErrorType? { ) } +@Serializable +sealed class ConnectionPlan { + @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() + @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() + @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() +} + +@Serializable +sealed class InvitationLinkPlan { + @Serializable @SerialName("ok") object Ok: InvitationLinkPlan() + @Serializable @SerialName("ownLink") object OwnLink: InvitationLinkPlan() + @Serializable @SerialName("connecting") class Connecting(val contact_: Contact? = null): InvitationLinkPlan() + @Serializable @SerialName("known") class Known(val contact: Contact): InvitationLinkPlan() +} + +@Serializable +sealed class ContactAddressPlan { + @Serializable @SerialName("ok") object Ok: ContactAddressPlan() + @Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan() + @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() + @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() + @Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan() +} + +@Serializable +sealed class GroupLinkPlan { + @Serializable @SerialName("ok") object Ok: GroupLinkPlan() + @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() + @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() + @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() + @Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan() +} + abstract class TerminalItem { abstract val id: Long val date: Instant = Clock.System.now() @@ -3874,6 +3921,7 @@ sealed class ChatErrorType { is ChatNotStarted -> "chatNotStarted" is ChatNotStopped -> "chatNotStopped" is ChatStoreChanged -> "chatStoreChanged" + is ConnectionPlanChatError -> "connectionPlan" is InvalidConnReq -> "invalidConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" @@ -3950,6 +3998,7 @@ sealed class ChatErrorType { @Serializable @SerialName("chatNotStarted") object ChatNotStarted: ChatErrorType() @Serializable @SerialName("chatNotStopped") object ChatNotStopped: ChatErrorType() @Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType() + @Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 1ba742b034..01173157a9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -31,10 +31,10 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.updateChatSettings +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -309,9 +309,9 @@ fun ChatInfoLayout( if (contact.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) val clipboard = LocalClipboardManager.current - ShareAddressButton { clipboard.shareText(contact.contactLink) } + ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) } SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 7c767f9b7e..7e1c03130a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.shareText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable @@ -44,14 +44,12 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St createLink() } } - val clipboard = LocalClipboardManager.current GroupLinkLayout( groupLink = groupLink, groupInfo, groupLinkMemberRole, creatingLink, createLink = ::createLink, - share = { clipboard.shareText(groupLink ?: return@GroupLinkLayout) }, updateLink = { val role = groupLinkMemberRole.value if (role != null) { @@ -95,7 +93,6 @@ fun GroupLinkLayout( groupLinkMemberRole: MutableState, creatingLink: Boolean, createLink: () -> Unit, - share: () -> Unit, updateLink: () -> Unit, deleteLink: () -> Unit ) { @@ -125,16 +122,17 @@ fun GroupLinkLayout( } initialLaunch = false } - QRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) + SimpleXLinkQRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) ) { + val clipboard = LocalClipboardManager.current SimpleButton( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), - click = share + click = { clipboard.shareText(simplexChatLink(groupLink)) } ) SimpleButton( stringResource(MR.strings.delete_link), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index c76fb73a6b..e281c57622 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -284,9 +284,9 @@ fun GroupMemberInfoLayout( if (member.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) val clipboard = LocalClipboardManager.current - ShareAddressButton { clipboard.shareText(member.contactLink) } + ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } if (contactId != null) { if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) @@ -473,43 +473,17 @@ private fun updateMemberRoleDialog( } fun connectViaMemberAddressAlert(connReqUri: String) { - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.connect_via_member_address_alert_title), - text = AnnotatedString(generalGetString(MR.strings.connect_via_member_address_alert_desc)), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - withApi { - Log.d(TAG, "connectViaUri: connecting") - connectViaUri(chatModel, linkType, uri, incognito = false) - } - } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - withApi { - Log.d(TAG, "connectViaUri: connecting incognito") - connectViaUri(chatModel, linkType, uri, incognito = true) - } - } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } + try { + val uri = URI(connReqUri) + withApi { + planAndConnect(chatModel, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) } - ) + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_connection_link), + text = generalGetString(MR.strings.this_string_is_not_a_connection_link) + ) + } } @Preview diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index b45543df4e..b16bf4c9ba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -130,6 +130,13 @@ suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { } } +suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) + if (chat != null) { + openChat(chat, chatModel) + } +} + suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 6d7450a213..66ef1cf9f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -316,46 +316,8 @@ fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = uri } else { - withUriAction(uri) { linkType -> - val title = when (linkType) { - ConnectionLinkType.CONTACT -> generalGetString(MR.strings.connect_via_contact_link) - ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link) - ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link) - } - AlertManager.shared.showAlertDialogButtonsColumn( - title = title, - text = if (linkType == ConnectionLinkType.GROUP) - AnnotatedString(generalGetString(MR.strings.you_will_join_group)) - else - AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - withApi { - Log.d(TAG, "connectIfOpenedViaUri: connecting") - connectViaUri(chatModel, linkType, uri, incognito = false) - } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - withApi { - Log.d(TAG, "connectIfOpenedViaUri: connecting incognito") - connectViaUri(chatModel, linkType, uri, incognito = true) - } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - } - ) + withApi { + planAndConnect(chatModel, uri, incognito = null, close = null) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index 8542ea52a4..ef3633d1fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -34,7 +34,6 @@ fun AddContactView( incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, contactConnection = contactConnection, - share = { clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.center.showModal { Column( @@ -56,7 +55,6 @@ fun AddContactLayout( incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, - share: () -> Unit, learnMore: () -> Unit ) { val incognito = remember { mutableStateOf(incognitoPref.get()) } @@ -82,7 +80,7 @@ fun AddContactLayout( SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) { if (connReq.isNotEmpty()) { - QRCode( + SimpleXLinkQRCode( connReq, Modifier .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) .aspectRatio(1f) @@ -99,7 +97,7 @@ fun AddContactLayout( } IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } - ShareLinkButton(share) + ShareLinkButton(connReq) OneTimeLinkLearnMoreButton(learnMore) } SectionTextFooter(sharedProfileInfo(chatModel, incognito.value)) @@ -109,11 +107,12 @@ fun AddContactLayout( } @Composable -fun ShareLinkButton(onClick: () -> Unit) { +fun ShareLinkButton(connReqInvitation: String) { + val clipboard = LocalClipboardManager.current SettingsActionItem( painterResource(MR.images.ic_share), stringResource(MR.strings.share_invitation_link), - onClick, + click = { clipboard.shareText(simplexChatLink(connReqInvitation)) }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, ) @@ -177,7 +176,6 @@ fun PreviewAddContactView() { incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", contactConnection = mutableStateOf(PendingContactConnection.getSampleData()), - share = {}, learnMore = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 934c050d8a..d04a85d905 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -131,13 +131,13 @@ private fun ContactConnectionInfoLayout( SectionView { if (!connReq.isNullOrEmpty() && contactConnection.initiated) { - QRCode( + SimpleXLinkQRCode( connReq, Modifier .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) .aspectRatio(1f) ) incognitoEnabled() - ShareLinkButton(share) + ShareLinkButton(connReq) OneTimeLinkLearnMoreButton(learnMore) } else { incognitoEnabled() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index 0c13bd4f68..f7a5a1e86b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -53,32 +53,14 @@ fun PasteToConnectLayout( fun connectViaLink(connReqUri: String) { try { val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - val action = suspend { - Log.d(TAG, "connectViaUri: connecting") - if (connectViaUri(chatModel, linkType, uri, incognito = incognito.value)) { - close() - } - } - if (linkType == ConnectionLinkType.GROUP) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), - confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { action() } } - ) - } else action() + withApi { + planAndConnect(chatModel, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.invalid_connection_link), text = generalGetString(MR.strings.this_string_is_not_a_connection_link) ) - } catch (e: URISyntaxException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_connection_link), - text = generalGetString(MR.strings.this_string_is_not_a_connection_link) - ) } } @@ -115,6 +97,7 @@ fun PasteToConnectLayout( painterResource(MR.images.ic_link), stringResource(MR.strings.connect_button), click = { connectViaLink(connectionLink.value) }, + disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ") ) IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 6632925964..763addae66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -19,6 +19,29 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch +@Composable +fun SimpleXLinkQRCode( + connReq: String, + modifier: Modifier = Modifier, + tintColor: Color = Color(0xff062d56), + withLogo: Boolean = true +) { + QRCode( + simplexChatLink(connReq), + modifier, + tintColor, + withLogo + ) +} + +fun simplexChatLink(uri: String): String { + return if (uri.startsWith("simplex:/")) { + uri.replace("simplex:/", "https://simplex.chat/") + } else { + uri + } +} + @Composable fun QRCode( connReq: String, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index e3fa922755..2f52c2cacf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer +import SectionItemView import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview import chat.simplex.common.platform.Log @@ -10,66 +11,265 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.openDirectChat +import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import java.net.URI @Composable expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) enum class ConnectionLinkType { - CONTACT, INVITATION, GROUP + INVITATION, CONTACT, GROUP } -@Serializable -sealed class CReqClientData { - @Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData() -} - -fun withUriAction(uri: URI, run: suspend (ConnectionLinkType) -> Unit) { - val action = uri.path?.drop(1)?.replace("/", "") - val data = URI(uri.toString().replaceFirst("#/", "/")).getQueryParameter("data") - val type = when { - data != null -> { - val parsed = runCatching { - json.decodeFromString(CReqClientData.serializer(), data) +suspend fun planAndConnect( + chatModel: ChatModel, + uri: URI, + incognito: Boolean?, + close: (() -> Unit)? +) { + val connectionPlan = chatModel.controller.apiConnectPlan(uri.toString()) + if (connectionPlan != null) { + when (connectionPlan) { + is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { + InvitationLinkPlan.Ok -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_invitation_link), + text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), + connectDestructive = false + ) + } + } + InvitationLinkPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .OwnLink, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link)), + connectDestructive = true + ) + } + } + is InvitationLinkPlan.Connecting -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting, incognito=$incognito") + val contact = connectionPlan.invitationLinkPlan.contact_ + if (contact != null) { + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + ) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_already_connecting), + generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + ) + } + } + is InvitationLinkPlan.Known -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") + val contact = connectionPlan.invitationLinkPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + ) + } } - when { - parsed.getOrNull() is CReqClientData.Group -> ConnectionLinkType.GROUP - else -> null + is ConnectionPlan.ContactAddress -> when (connectionPlan.contactAddressPlan) { + ContactAddressPlan.Ok -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_contact_link), + text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), + connectDestructive = false + ) + } + } + ContactAddressPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .OwnLink, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address)), + connectDestructive = true + ) + } + } + ContactAddressPlan.ConnectingConfirmReconnect -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingConfirmReconnect, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_repeat_connection_request), + text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_repeat_connection_request), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address)), + connectDestructive = true + ) + } + } + is ContactAddressPlan.ConnectingProhibit -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") + val contact = connectionPlan.contactAddressPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + ) + } + is ContactAddressPlan.Known -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") + val contact = connectionPlan.contactAddressPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + ) + } + } + is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { + GroupLinkPlan.Ok -> { + Log.d(TAG, "planAndConnect, .GroupLink, .Ok, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_via_group_link), + text = generalGetString(MR.strings.you_will_join_group), + confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } } + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_group_link), + text = AnnotatedString(generalGetString(MR.strings.you_will_join_group)), + connectDestructive = false + ) + } + } + is GroupLinkPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo + ownGroupLinkConfirmConnect(chatModel, uri, incognito, connectionPlan, groupInfo, close) + } + GroupLinkPlan.ConnectingConfirmReconnect -> { + Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_repeat_join_request), + text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), + confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_repeat_join_request), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link)), + connectDestructive = true + ) + } + } + is GroupLinkPlan.ConnectingProhibit -> { + Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingProhibit, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo_ + if (groupInfo != null) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_group_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) + ) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_already_joining_the_group), + generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + ) + } + } + is GroupLinkPlan.Known -> { + Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo + openKnownGroup(chatModel, close, groupInfo) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_group_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) + ) + } } } - - action == "contact" -> ConnectionLinkType.CONTACT - action == "invitation" -> ConnectionLinkType.INVITATION - else -> null - } - if (type != null) { - withApi { run(type) } } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_contact_link), - text = generalGetString(MR.strings.this_link_is_not_a_valid_connection_link) - ) + Log.d(TAG, "planAndConnect, plan error") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan = null, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan = null, close, + title = generalGetString(MR.strings.connect_plan_connect_via_link), + connectDestructive = false + ) + } } } -suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI, incognito: Boolean): Boolean { +suspend fun connectViaUri( + chatModel: ChatModel, + uri: URI, + incognito: Boolean, + connectionPlan: ConnectionPlan?, + close: (() -> Unit)? +): Boolean { val r = chatModel.controller.apiConnect(incognito, uri.toString()) + val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (r) { + close?.invoke() AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = - when (action) { + when (connLinkType) { ConnectionLinkType.CONTACT -> generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) ConnectionLinkType.INVITATION -> generalGetString(MR.strings.you_will_be_connected_when_your_contacts_device_is_online) ConnectionLinkType.GROUP -> generalGetString(MR.strings.you_will_be_connected_when_group_host_device_is_online) @@ -79,6 +279,139 @@ suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: return r } +fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { + return when(connectionPlan) { + is ConnectionPlan.InvitationLink -> ConnectionLinkType.INVITATION + is ConnectionPlan.ContactAddress -> ConnectionLinkType.CONTACT + is ConnectionPlan.GroupLink -> ConnectionLinkType.GROUP + } +} + +fun askCurrentOrIncognitoProfileAlert( + chatModel: ChatModel, + uri: URI, + connectionPlan: ConnectionPlan?, + close: (() -> Unit)?, + title: String, + text: AnnotatedString? = null, + connectDestructive: Boolean, +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = title, + text = text, + buttons = { + Column { + val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +fun openKnownContact(chatModel: ChatModel, close: (() -> Unit)?, contact: Contact) { + withApi { + val c = chatModel.getContactChat(contact.contactId) + if (c != null) { + close?.invoke() + openDirectChat(contact.contactId, chatModel) + } + } +} + +fun ownGroupLinkConfirmConnect( + chatModel: ChatModel, + uri: URI, + incognito: Boolean?, + connectionPlan: ConnectionPlan?, + groupInfo: GroupInfo, + close: (() -> Unit)?, +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_join_your_group), + text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)), + buttons = { + Column { + // Open group + SectionItemView({ + AlertManager.shared.hideAlert() + openKnownGroup(chatModel, close, groupInfo) + }) { + Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (incognito != null) { + // Join incognito / Join with current profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } + }) { + Text( + if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error + ) + } + } else { + // Use current profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Use new incognito profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupInfo) { + withApi { + val g = chatModel.getGroupChat(groupInfo.groupId) + if (g != null) { + close?.invoke() + openGroupChat(groupInfo.groupId, chatModel) + } + } +} + @Composable fun ConnectContactLayout( chatModel: ChatModel, @@ -92,21 +425,8 @@ fun ConnectContactLayout( QRCodeScanner { connReqUri -> try { val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - val action = suspend { - Log.d(TAG, "connectViaUri: connecting") - if (connectViaUri(ChatModel, linkType, uri, incognito = incognito.value)) { - close() - } - } - if (linkType == ConnectionLinkType.GROUP) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), - confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { action() } } - ) - } else action() + withApi { + planAndConnect(chatModel, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 72cbc3a628..0132223383 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -18,7 +18,8 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.newchat.SimpleXLinkQRCode +import chat.simplex.common.views.newchat.simplexChatLink import chat.simplex.res.MR @Composable @@ -38,7 +39,7 @@ fun CreateSimpleXAddress(m: ChatModel) { sendEmail = { address -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(address.connReqContact) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink(address.connReqContact)) ) }, createAddress = { @@ -91,8 +92,8 @@ private fun CreateSimpleXAddressLayout( Spacer(Modifier.weight(1f)) if (userAddress != null) { - QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { share(userAddress.connReqContact) } + SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } Spacer(Modifier.weight(1f)) ShareViaEmailButton { sendEmail(userAddress) } Spacer(Modifier.weight(1f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 63f06a2aec..d03b758565 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -24,10 +24,10 @@ import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ShareAddressButton import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.* +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable @@ -100,7 +100,7 @@ fun UserAddressView( sendEmail = { userAddress -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(userAddress.connReqContact) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink( userAddress.connReqContact)) ) }, setProfileAddress = ::setProfileAddress, @@ -201,8 +201,8 @@ private fun UserAddressLayout( val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) } val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) } SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { share(userAddress.connReqContact) } + SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } ShareViaEmailButton { sendEmail(userAddress) } ShareWithContactsButton(shareViaProfile, setProfileAddress) AutoAcceptToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 114fe49e92..bd59d236d5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -4,13 +4,13 @@ k - Connect via contact link? - Connect via invitation link? - Connect via group link? + Connect via contact address? + Connect via one-time link? + Join group? Use current profile Use new incognito profile Your profile will be sent to the contact that you received this link from. - You will join a group this link refers to and connect to its group members. + You will connect to all group members. Connect Connect incognito @@ -1600,4 +1600,25 @@ Coming soon! This feature is not yet supported. Try the next release. + + + Connect to yourself? + This is your own one-time link! + You are already connecting to %1$s. + Already connecting! + You are already connecting via this one-time link! + This is your own SimpleX address! + Repeat connection request? + You have already requested connection via this address! + Join your group? + This is your link for group %1$s! + Open group + Repeat join request? + You are already joining the group via this link! + Group already exists! + You are already joining the group %1$s. + Already joining the group! + You are already joining the group via this link. + You are already in group %1$s. + Connect via link? \ No newline at end of file From 2219cea0268ca13eff56978c9898d515cbdcaf31 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 18 Oct 2023 21:15:37 +0100 Subject: [PATCH 43/80] core: fix type for JSON --- src/Simplex/Chat/Messages.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 21da83ad05..2ddb1e7bcd 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -933,7 +933,7 @@ instance ToJSON (CIDeleted d) where data JSONCIDeleted = JCIDDeleted {deletedTs :: Maybe UTCTime} - | JCIBlocked {deletedTs :: Maybe UTCTime} + | JCIDBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} deriving (Show, Generic) @@ -944,7 +944,7 @@ instance ToJSON JSONCIDeleted where jsonCIDeleted :: CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case CIDeleted ts -> JCIDDeleted ts - CIBlocked ts -> JCIBlocked ts + CIBlocked ts -> JCIDBlocked ts CIModerated ts m -> JCIDModerated ts m itemDeletedTs :: CIDeleted d -> Maybe UTCTime From c090b68bdda6073aeca7ac94dfde8b654eec5aba Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 19 Oct 2023 19:52:59 +0400 Subject: [PATCH 44/80] ios, android: ask to notify contact or not on contact deletion (#3247) --- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +-- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 65 +++++++++-------- .../Views/ChatList/ChatListNavLink.swift | 42 ++++++----- apps/ios/SimpleXChat/APITypes.swift | 8 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 12 ++-- .../simplex/common/views/chat/ChatInfoView.kt | 69 +++++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 1 + 7 files changed, 137 insertions(+), 68 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index bad15ad52f..99e8c02847 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -671,18 +671,18 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert { } } -func apiDeleteChat(type: ChatType, id: Int64) async throws { - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) +func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws { + let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .group = type, case .groupDeletedUser = r { return } throw r } -func deleteChat(_ chat: Chat) async { +func deleteChat(_ chat: Chat, notify: Bool? = nil) async { do { let cInfo = chat.chatInfo - try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId) + try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify) DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) } } catch let error { logger.error("deleteChat apiDeleteChat error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 5438eb13bc..ec4cc0fc4c 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -99,12 +99,12 @@ struct ChatInfoView: View { @Binding var connectionCode: String? @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil + @State private var showDeleteContactActionSheet = false @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum ChatInfoViewAlert: Identifiable { - case deleteContactAlert case clearChatAlert case networkStatusAlert case switchAddressAlert @@ -114,7 +114,6 @@ struct ChatInfoView: View { var id: String { switch self { - case .deleteContactAlert: return "deleteContactAlert" case .clearChatAlert: return "clearChatAlert" case .networkStatusAlert: return "networkStatusAlert" case .switchAddressAlert: return "switchAddressAlert" @@ -233,7 +232,6 @@ struct ChatInfoView: View { } .alert(item: $alert) { alertItem in switch(alertItem) { - case .deleteContactAlert: return deleteContactAlert() case .clearChatAlert: return clearChatAlert() case .networkStatusAlert: return networkStatusAlert() case .switchAddressAlert: return switchAddressAlert(switchContactAddress) @@ -242,6 +240,26 @@ struct ChatInfoView: View { case let .error(title, error): return mkAlert(title: title, message: error) } } + .actionSheet(isPresented: $showDeleteContactActionSheet) { + if contact.ready && contact.active { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { deleteContact(notify: true) }, + .destructive(Text("Delete")) { deleteContact(notify: false) }, + .cancel() + ] + ) + } else { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { deleteContact() }, + .cancel() + ] + ) + } + } } private func contactInfoHeader() -> some View { @@ -414,7 +432,7 @@ struct ChatInfoView: View { private func deleteContactButton() -> some View { Button(role: .destructive) { - alert = .deleteContactAlert + showDeleteContactActionSheet = true } label: { Label("Delete contact", systemImage: "trash") .foregroundColor(Color.red) @@ -430,30 +448,23 @@ struct ChatInfoView: View { } } - private func deleteContactAlert() -> Alert { - Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - Task { - do { - try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) - await MainActor.run { - dismiss() - chatModel.chatId = nil - chatModel.removeChat(chat.chatInfo.id) - } - } catch let error { - logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") - let a = getErrorAlert(error, "Error deleting contact") - await MainActor.run { - alert = .error(title: a.title, error: a.message) - } - } + private func deleteContact(notify: Bool? = nil) { + Task { + do { + try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify) + await MainActor.run { + dismiss() + chatModel.chatId = nil + chatModel.removeChat(chat.chatInfo.id) } - }, - secondaryButton: .cancel() - ) + } catch let error { + logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") + let a = getErrorAlert(error, "Error deleting contact") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } } private func clearChatAlert() -> Alert { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f445ae4b5d..be912d666a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -32,6 +32,7 @@ struct ChatListNavLink: View { @State private var showJoinGroupDialog = false @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false + @State private var showDeleteContactActionSheet = false var body: some View { switch chat.chatInfo { @@ -64,17 +65,37 @@ struct ChatListNavLink: View { clearChatButton() } Button { - AlertManager.shared.showAlert( - contact.ready || !contact.active - ? deleteContactAlert(chat.chatInfo) - : deletePendingContactAlert(chat, contact) - ) + if contact.ready || !contact.active { + showDeleteContactActionSheet = true + } else { + AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) + } } label: { Label("Delete", systemImage: "trash") } .tint(.red) } .frame(height: rowHeights[dynamicTypeSize]) + .actionSheet(isPresented: $showDeleteContactActionSheet) { + if contact.ready && contact.active { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } }, + .destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } }, + .cancel() + ] + ) + } else { + ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, + .cancel() + ] + ) + } + } } @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { @@ -269,17 +290,6 @@ struct ChatListNavLink: View { } } - private func deleteContactAlert(_ chatInfo: ChatInfo) -> Alert { - Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - Task { await deleteChat(chat) } - }, - secondaryButton: .cancel() - ) - } - private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { Alert( title: Text("Delete group?"), diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 53da91b042..5c7220f374 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -89,7 +89,7 @@ public enum ChatCommand { case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) - case apiDeleteChat(type: ChatType, id: Int64) + case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) case apiUpdateProfile(userId: Int64, profile: Profile) @@ -224,7 +224,11 @@ public enum ChatCommand { case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" - case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" + case let .apiDeleteChat(type, id, notify): if let notify = notify { + return "/_delete \(ref(type, id)) notify=\(onOff(notify))" + } else { + return "/_delete \(ref(type, id))" + } case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 8397d2edb1..da09ea132f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -904,8 +904,8 @@ object ChatController { } } - suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean { - val r = sendCmd(CC.ApiDeleteChat(type, id)) + suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean { + val r = sendCmd(CC.ApiDeleteChat(type, id, notify)) when { r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true @@ -1924,7 +1924,7 @@ sealed class CC { class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() - class ApiDeleteChat(val type: ChatType, val id: Long): CC() + class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() @@ -2034,7 +2034,11 @@ sealed class CC { is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" - is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" + is ApiDeleteChat -> if (notify != null) { + "/_delete ${chatRef(type, id)} notify=${onOff(notify)}" + } else { + "/_delete ${chatRef(type, id)}" + } is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 01173157a9..4564a4a6e8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -196,28 +196,67 @@ sealed class SendReceipts { } fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { - AlertManager.shared.showAlertDialog( + AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), - text = generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning), - confirmText = generalGetString(MR.strings.delete_verb), - onConfirm = { - withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) - if (r) { - chatModel.removeChat(chatInfo.id) - if (chatModel.chatId.value == chatInfo.id) { - chatModel.chatId.value = null - ModalManager.end.closeModals() + text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), + buttons = { + Column { + if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) { + // Delete and notify contact + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close, notify = true) + } + }) { + Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - ntfManager.cancelNotificationsForChat(chatInfo.id) - close?.invoke() + // Delete + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close, notify = false) + } + }) { + Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } else { + // Delete + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close) + } + }) { + Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - }, - destructive = true, + } ) } +fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { + withApi { + val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify) + if (r) { + chatModel.removeChat(chatInfo.id) + if (chatModel.chatId.value == chatInfo.id) { + chatModel.chatId.value = null + ModalManager.end.closeModals() + } + ntfManager.cancelNotificationsForChat(chatInfo.id) + close?.invoke() + } + } +} + fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index bd59d236d5..1ae85cd050 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -350,6 +350,7 @@ Delete contact? Contact and all messages will be deleted - this cannot be undone! + Delete and notify contact Delete contact Set contact name… Connected From 87d84cfccce5007cf404898b20d2142d451f9b1c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 21 Oct 2023 19:13:32 +0400 Subject: [PATCH 45/80] core: filter connection plan query results by user_id (#3251) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 2 +- .../Chat/Migrations/M20231019_indexes.hs | 32 +++++++++++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 13 +++++--- src/Simplex/Chat/Store/Connections.hs | 10 +++--- src/Simplex/Chat/Store/Direct.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 6 ++-- src/Simplex/Chat/Store/Migrations.hs | 4 ++- src/Simplex/Chat/Store/Profiles.hs | 8 ++--- tests/SchemaDump.hs | 4 ++- 10 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231019_indexes.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b431b0ddf3..824b558135 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -117,6 +117,7 @@ library Simplex.Chat.Migrations.M20231002_conn_initiated Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash Simplex.Chat.Migrations.M20231010_member_settings + Simplex.Chat.Migrations.M20231019_indexes Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7e2c2b71e7..33dd262b01 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2249,7 +2249,7 @@ processChatCommand = \case case groupLinkId of -- contact address Nothing -> - withStore' (`getUserContactLinkByConnReq` cReqSchemas) >>= \case + withStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> do withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHashes) >>= \case diff --git a/src/Simplex/Chat/Migrations/M20231019_indexes.hs b/src/Simplex/Chat/Migrations/M20231019_indexes.hs new file mode 100644 index 0000000000..40412e1778 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231019_indexes.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231019_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231019_indexes :: Query +m20231019_indexes = + [sql| +DROP INDEX idx_connections_conn_req_inv; +CREATE INDEX idx_connections_conn_req_inv ON connections(user_id, conn_req_inv); + +DROP INDEX idx_groups_via_group_link_uri_hash; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(user_id, via_group_link_uri_hash); + +DROP INDEX idx_connections_via_contact_uri_hash; +CREATE INDEX idx_connections_via_contact_uri_hash ON connections(user_id, via_contact_uri_hash); +|] + +down_m20231019_indexes :: Query +down_m20231019_indexes = + [sql| +DROP INDEX idx_connections_conn_req_inv; +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); + +DROP INDEX idx_groups_via_group_link_uri_hash; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(via_group_link_uri_hash); + +DROP INDEX idx_connections_via_contact_uri_hash; +CREATE INDEX idx_connections_via_contact_uri_hash ON connections(via_contact_uri_hash); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 7308ef89ff..cadb7caf42 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -524,9 +524,6 @@ CREATE INDEX contact_profiles_index ON contact_profiles( full_name ); CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); -CREATE INDEX idx_connections_via_contact_uri_hash ON connections( - via_contact_uri_hash -); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); @@ -738,7 +735,15 @@ CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); -CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); +CREATE INDEX idx_connections_conn_req_inv ON connections( + user_id, + conn_req_inv +); CREATE INDEX idx_groups_via_group_link_uri_hash ON groups( + user_id, via_group_link_uri_hash ); +CREATE INDEX idx_connections_via_contact_uri_hash ON connections( + user_id, + via_contact_uri_hash +); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 59ffb57c6e..d73ac705d3 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -155,9 +155,9 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ _ = Left SEUserContactLinkNotFound getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) -getConnectionEntityByConnReq db user (cReqSchema1, cReqSchema2) = do +getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ - DB.query db "SELECT agent_conn_id FROM connections WHERE conn_req_inv IN (?,?) LIMIT 1" (cReqSchema1, cReqSchema2) + DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ -- search connection for connection plan: @@ -165,7 +165,7 @@ getConnectionEntityByConnReq db user (cReqSchema1, cReqSchema2) = do -- this function searches for latest connection with contact so that "known contact" plan would be chosen; -- deleted connections are filtered out to allow re-connecting via same contact address getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) -getContactConnEntityByConnReqHash db user (cReqHash1, cReqHash2) = do +getContactConnEntityByConnReqHash db user@User {userId} (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ DB.query db @@ -175,12 +175,12 @@ getContactConnEntityByConnReqHash db user (cReqHash1, cReqHash2) = do agent_conn_id, (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord FROM connections - WHERE via_contact_uri_hash IN (?,?) AND conn_status != ? + WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? ORDER BY conn_ord DESC, created_at DESC LIMIT 1 ) |] - (cReqHash1, cReqHash2, ConnDeleted) + (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7227797193..563cc337e0 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -167,7 +167,7 @@ getContactByConnReqHash db user@User {userId} cReqHash = FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 ORDER BY c.created_at DESC LIMIT 1 |] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a4a19816da..76d68cc6be 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1122,16 +1122,16 @@ getGroupInfo db User {userId, userContactId} groupId = (groupId, userId, userContactId) getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) -getGroupInfoByUserContactLinkConnReq db user (cReqSchema1, cReqSchema2) = do +getGroupInfoByUserContactLinkConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query db [sql| SELECT group_id FROM user_contact_links - WHERE conn_req_contact IN (?,?) + WHERE user_id = ? AND conn_req_contact IN (?,?) |] - (cReqSchema1, cReqSchema2) + (userId, cReqSchema1, cReqSchema2) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 60783f3664..357bfd9a28 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -85,6 +85,7 @@ import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash import Simplex.Chat.Migrations.M20231010_member_settings +import Simplex.Chat.Migrations.M20231019_indexes import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -169,7 +170,8 @@ schemaMigrations = ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash), - ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings) + ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), + ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c9c4806496..80499dee82 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -441,17 +441,17 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) -getUserContactLinkByConnReq :: DB.Connection -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) -getUserContactLinkByConnReq db (cReqSchema1, cReqSchema2) = +getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) +getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = maybeFirstRow toUserContactLink $ DB.query db [sql| SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links - WHERE conn_req_contact IN (?,?) + WHERE user_id = ? AND conn_req_contact IN (?,?) |] - (cReqSchema1, cReqSchema2) + (userId, cReqSchema1, cReqSchema2) updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index f4538e4b3b..f517d13df1 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -71,7 +71,9 @@ skipComparisonForDownMigrations = -- on down migration idx_chat_items_timed_delete_at index moves down to the end of the file "20230529_indexes", -- table and index definitions move down the file, so fields are re-created as not unique - "20230914_member_probes" + "20230914_member_probes", + -- on down migration idx_connections_via_contact_uri_hash index moves down to the end of the file + "20231019_indexes" ] getSchema :: FilePath -> FilePath -> IO String From 5c57987e9f321c98a079a495f8104b0b57d4a718 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 13:58:51 +0100 Subject: [PATCH 46/80] add smp11, 12 and 14 to preset servers --- src/Simplex/Chat.hs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 33dd262b01..3581b80c29 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -150,7 +150,10 @@ defaultChatConfig = _defaultSMPServers :: NonEmpty SMPServerWithAuth _defaultSMPServers = L.fromList - [ "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", + [ "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", + "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", From 8891314507a5f5fc05c540ca83b80391dc4e7a87 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 14:19:24 +0100 Subject: [PATCH 47/80] core: update simplexmq (fixes ordering issue during message delivery) --- cabal.project | 4 ++-- scripts/nix/sha256map.nix | 4 ++-- stack.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index 9a9a3e25da..8a05c0bb95 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + tag: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc source-repository-package type: git @@ -19,7 +19,7 @@ source-repository-package source-repository-package type: git location: https://github.com/kazu-yamamoto/http2.git - tag: b5a1b7200cf5bc7044af34ba325284271f6dff25 + tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 17d650cb09..60b9505fea 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."1ad69cf74f18f25713ce564e1629d2538313b9e0" = "1kil0962pn3ksnxh7dcwcbnkidz95yl31rm4m585ps7wnh6fp0l9"; + "https://github.com/simplex-chat/simplexmq.git"."cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc" = "0xcbvxz2nszm1sdh6gvmfzjf9n2ldsarmmzbl6j6b5hg9i1mppc6"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; + "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/stack.yaml b/stack.yaml index 6e047f7e6c..99b9d179cd 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: 1ad69cf74f18f25713ce564e1629d2538313b9e0 + commit: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc - github: kazu-yamamoto/http2 - commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 + commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9 From b25c2e3a091e7bf7258c9485f790787a346835a9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 14:56:51 +0100 Subject: [PATCH 48/80] ui: add 10 minutes SimpleX Lock delay (#3255) --- apps/ios/Shared/Views/UserSettings/PrivacySettings.swift | 4 +++- .../chat/simplex/common/views/usersettings/PrivacySettings.kt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 71ff7b88bf..51bfb96940 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -356,7 +356,7 @@ struct SimplexLockView: View { var id: Self { self } } - let laDelays: [Int] = [10, 30, 60, 180, 0] + let laDelays: [Int] = [10, 30, 60, 180, 600, 0] func laDelayText(_ t: Int) -> LocalizedStringKey { let m = t / 60 @@ -378,6 +378,7 @@ struct SimplexLockView: View { Text(mode.text) } } + .frame(height: 36) if performLA { Picker("Lock after", selection: $laLockDelay) { let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays @@ -385,6 +386,7 @@ struct SimplexLockView: View { Text(laDelayText(t)) } } + .frame(height: 36) if showChangePassword && laMode == .passcode { Button("Change passcode") { changeLAPassword() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 211fe59671..84ab87c653 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -317,7 +317,7 @@ private fun showUserGroupsReceiptsAlert( ) } -private val laDelays = listOf(10, 30, 60, 180, 0) +private val laDelays = listOf(10, 30, 60, 180, 600, 0) @Composable fun SimplexLockView( From 79275424ea4b650e408262491c67495fd7431690 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 15:06:55 +0100 Subject: [PATCH 49/80] core: 5.4.0.2 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 6fed41b2ae..6f333d9bb1 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.1 +version: 5.4.0.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 824b558135..cdb659a66e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.1 +version: 5.4.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 3e0b6826bf13255cab1e5b90833e08e92f884b04 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 17:50:08 +0100 Subject: [PATCH 50/80] ios: 5.3.2 build 179 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1cbe61dea0..c75fa63c52 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1486,7 +1486,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1528,7 +1528,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1608,7 +1608,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1640,7 +1640,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1672,7 +1672,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1718,7 +1718,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 178; + CURRENT_PROJECT_VERSION = 179; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; From 1401f562882b4a3d341ac41a8330c6adb031a86a Mon Sep 17 00:00:00 2001 From: Era Dorta Date: Sun, 22 Oct 2023 19:15:15 +0200 Subject: [PATCH 51/80] cli: update docker image to use ghc 9.6.2 (#3234) --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index b78e2f2448..0c0788c81d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/ chmod +x /usr/bin/ghcup # Install ghc -RUN ghcup install ghc 8.10.7 +RUN ghcup install ghc 9.6.2 # Install cabal -RUN ghcup install cabal +RUN ghcup install cabal 3.10.1.0 # Set both as default -RUN ghcup set ghc 8.10.7 && \ - ghcup set cabal +RUN ghcup set ghc 9.6.2 && \ + ghcup set cabal 3.10.1.0 COPY . /project WORKDIR /project From 530ec701711c1767e71457ce5c0de7ae35ff0f24 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 23 Oct 2023 01:47:27 +0800 Subject: [PATCH 52/80] android, desktop: support calls on desktop and moved www dir to different root (#3219) * android, desktop: support calls on desktop and moved www dir to different root * add page title, fix links on Android, change timeouts * using worker in desktop Chrome and Safari * ui changes * end call button in app bar * fix android * a lot of enhancements * fix after merge master * layout * sound play on call --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/multiplatform/android/build.gradle.kts | 1 + apps/multiplatform/common/build.gradle.kts | 2 + .../common/views/call/CallView.android.kt | 86 ++++--- .../views/chatlist/ChatListView.android.kt | 8 + .../chat/simplex/common/model/ChatModel.kt | 2 +- .../chat/simplex/common/model/SimpleXAPI.kt | 10 +- .../simplex/common/views/call/CallManager.kt | 12 +- .../chat/simplex/common/views/call/WebRTC.kt | 43 ++-- .../simplex/common/views/chat/ChatView.kt | 75 ++++-- .../views/chat/group/GroupMemberInfoView.kt | 5 +- .../common/views/chat/item/CICallItemView.kt | 4 +- .../views/chatlist/ChatListNavLinkView.kt | 13 +- .../common/views/chatlist/ChatListView.kt | 8 + .../common/views/helpers/AlertManager.kt | 3 +- .../common/views/helpers/ChatInfoImage.kt | 29 +++ .../resources}/assets/www/README.md | 0 .../resources/assets/www/android}/call.html | 4 +- .../resources/assets/www/android}/style.css | 0 .../resources}/assets/www/call.html | 0 .../commonMain/resources}/assets/www/call.js | 58 ++++- .../resources/assets/www/desktop/call.html | 50 ++++ .../www/desktop/images/ic_call_end_filled.svg | 1 + .../assets/www/desktop/images/ic_mic.svg | 1 + .../assets/www/desktop/images/ic_mic_off.svg | 1 + .../www/desktop/images/ic_phone_in_talk.svg | 1 + .../www/desktop/images/ic_videocam_filled.svg | 1 + .../www/desktop/images/ic_videocam_off.svg | 1 + .../www/desktop/images/ic_volume_down.svg | 1 + .../www/desktop/images/ic_volume_up.svg | 1 + .../resources/assets/www/desktop/style.css | 127 ++++++++++ .../resources/assets/www/desktop/ui.js | 80 ++++++ .../resources}/assets/www/lz-string.min.js | 0 .../resources/assets/www}/style.css | 0 .../common/platform/RecAndPlay.desktop.kt | 32 ++- .../common/views/call/CallView.desktop.kt | 239 +++++++++++++++++- .../chatlist/ChatListNavLinkView.desktop.kt | 2 +- .../views/chatlist/ChatListView.desktop.kt | 75 ++++++ .../desktopMain/resources/media/ring_once.mp3 | Bin 0 -> 72269 bytes packages/simplex-chat-webrtc/copy | 24 +- packages/simplex-chat-webrtc/package.json | 2 +- .../simplex-chat-webrtc/src/android/call.html | 26 ++ .../simplex-chat-webrtc/src/android/style.css | 41 +++ packages/simplex-chat-webrtc/src/call.ts | 80 ++++-- .../simplex-chat-webrtc/src/desktop/call.html | 50 ++++ .../src/desktop/images/ic_call_end_filled.svg | 1 + .../src/desktop/images/ic_mic.svg | 1 + .../src/desktop/images/ic_mic_off.svg | 1 + .../src/desktop/images/ic_phone_in_talk.svg | 1 + .../src/desktop/images/ic_videocam_filled.svg | 1 + .../src/desktop/images/ic_videocam_off.svg | 1 + .../src/desktop/images/ic_volume_down.svg | 1 + .../src/desktop/images/ic_volume_up.svg | 1 + .../simplex-chat-webrtc/src/desktop/style.css | 127 ++++++++++ .../simplex-chat-webrtc/src/desktop/ui.ts | 87 +++++++ 54 files changed, 1262 insertions(+), 159 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/README.md (100%) rename {packages/simplex-chat-webrtc/src => apps/multiplatform/common/src/commonMain/resources/assets/www/android}/call.html (88%) rename apps/multiplatform/{android/src/main/assets/www => common/src/commonMain/resources/assets/www/android}/style.css (100%) rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/call.html (100%) rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/call.js (92%) create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js rename apps/multiplatform/{android/src/main => common/src/commonMain/resources}/assets/www/lz-string.min.js (100%) rename {packages/simplex-chat-webrtc/src => apps/multiplatform/common/src/commonMain/resources/assets/www}/style.css (100%) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 create mode 100644 packages/simplex-chat-webrtc/src/android/call.html create mode 100644 packages/simplex-chat-webrtc/src/android/style.css create mode 100644 packages/simplex-chat-webrtc/src/desktop/call.html create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg create mode 100644 packages/simplex-chat-webrtc/src/desktop/style.css create mode 100644 packages/simplex-chat-webrtc/src/desktop/ui.ts diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 67a8fea87b..873f33b22f 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -77,6 +77,7 @@ android { } jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0 } + android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets")) val isRelease = gradle.startParameter.taskNames.find { it.toLowerCase().contains("release") } != null val isBundle = gradle.startParameter.taskNames.find { it.toLowerCase().contains("bundle") } != null // if (isRelease) { diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 9fb40c93d8..13ca2c309d 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -98,6 +98,8 @@ kotlin { implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") implementation("uk.co.caprica:vlcj:4.7.3") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 260182b5a4..790345e97e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -43,6 +44,9 @@ import chat.simplex.res.MR import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -52,7 +56,7 @@ actual fun ActiveCallView() { val chatModel = ChatModel BackHandler(onBack = { val call = chatModel.activeCall.value - if (call != null) withApi { chatModel.callManager.endCall(call) } + if (call != null) withBGApi { chatModel.callManager.endCall(call) } }) val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } @@ -112,30 +116,30 @@ actual fun ActiveCallView() { if (call != null) { Log.d(TAG, "has active call $call") when (val r = apiMsg.resp) { - is WCallResponse.Capabilities -> withApi { + is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } - is WCallResponse.Offer -> withApi { + is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } - is WCallResponse.Answer -> withApi { + is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } - is WCallResponse.Ice -> withApi { + is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected) + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) setCallSound(call.soundSpeaker, audioViaBluetooth) } - withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } } catch (e: Error) { Log.d(TAG,"call status ${r.state.connectionState} not used") } @@ -145,9 +149,12 @@ actual fun ActiveCallView() { setCallSound(call.soundSpeaker, audioViaBluetooth) } } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } is WCallResponse.Ended -> { chatModel.activeCall.value = call.copy(callState = CallState.Ended) - withApi { chatModel.callManager.endCall(call) } + withBGApi { chatModel.callManager.endCall(call) } chatModel.showCallView.value = false } is WCallResponse.Ok -> when (val cmd = apiMsg.command) { @@ -162,7 +169,7 @@ actual fun ActiveCallView() { is WCallCommand.Camera -> { chatModel.activeCall.value = call.copy(localCamera = cmd.camera) if (!call.audioEnabled) { - chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false) + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) } } is WCallCommand.End -> @@ -187,11 +194,14 @@ actual fun ActiveCallView() { // Lock orientation to portrait in order to have good experience with calls activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } onDispose { activity.volumeControlStream = prevVolumeControlStream // Unlock orientation activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() } } } @@ -201,9 +211,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot ActiveCallOverlayLayout( call = call, speakerCanBeEnabled = !audioViaBluetooth.value, - dismiss = { withApi { chatModel.callManager.endCall(call) } }, - toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) }, - toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) }, + dismiss = { withBGApi { chatModel.callManager.endCall(call) } }, + toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) }, + toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) }, toggleSound = { var call = chatModel.activeCall.value if (call != null) { @@ -212,7 +222,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot setCallSound(call.soundSpeaker, audioViaBluetooth) } }, - flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) } + flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) } ) } @@ -439,7 +449,7 @@ private fun DisabledBackgroundCallsButton() { //} @Composable -fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { +fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val scope = rememberCoroutineScope() val webView = remember { mutableStateOf(null) } val permissionsState = rememberMultiplePermissionsState( @@ -470,13 +480,19 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webView.value = null } } - LaunchedEffect(callCommand.value, webView.value) { - val cmd = callCommand.value - val wv = webView.value - if (cmd != null && wv != null) { - Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") - processCommand(wv, cmd) - callCommand.value = null + val wv = webView.value + if (wv != null) { + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") + processCommand(wv, cmd) + } + } } } val assetLoader = WebViewAssetLoader.Builder() @@ -502,7 +518,7 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa } } } - this.webViewClient = LocalContentWebViewClient(assetLoader) + this.webViewClient = LocalContentWebViewClient(webView, assetLoader) this.clearHistory() this.clearCache(true) this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") @@ -512,19 +528,10 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webViewSettings.javaScriptEnabled = true webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE - this.loadUrl("file:android_asset/www/call.html") + this.loadUrl("file:android_asset/www/android/call.html") } } - ) { wv -> - Log.d(TAG, "WebRTCView: webview ready") - // for debugging - // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) - scope.launch { - delay(2000L) - wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) - webView.value = wv - } - } + ) { /* WebView */ } } } } @@ -539,19 +546,28 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) { // for debugging // onResponse(message) onResponse(json.decodeFromString(message)) - } catch (e: Error) { + } catch (e: Exception) { Log.e(TAG, "failed parsing WebView message: $message") } } } -private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { +private class LocalContentWebViewClient(val webView: MutableState, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) + webView.value = view + Log.d(TAG, "WebRTCView: webview ready") + // for debugging + // view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) + } } @Preview diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt new file mode 100644 index 0000000000..cb74664a48 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.runtime.* +import chat.simplex.common.views.helpers.* +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ad033387ab..767c678c1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -88,7 +88,7 @@ object ChatModel { val activeCallInvitation = mutableStateOf(null) val activeCall = mutableStateOf(null) val activeCallViewIsVisible = mutableStateOf(false) - val callCommand = mutableStateOf(null) + val callCommand = mutableStateListOf() val showCallView = mutableStateOf(false) val switchingCall = mutableStateOf(false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index da09ea132f..58850ce056 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1647,25 +1647,25 @@ object ChatController { val useRelay = appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, ".callOffer iceServers $iceServers") - chatModel.callCommand.value = WCallCommand.Offer( + chatModel.callCommand.add(WCallCommand.Offer( offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) } } is CR.CallAnswer -> { withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived) - chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)) } } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) } } is CR.CallEnded -> { @@ -1674,7 +1674,7 @@ object ChatController { chatModel.callManager.reportCallRemoteEnded(invitation = invitation) } withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.End + chatModel.callCommand.add(WCallCommand.End) withApi { chatModel.activeCall.value = null chatModel.showCallView.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index fe4da718a8..f601776f98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -3,8 +3,6 @@ package chat.simplex.common.views.call import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.withApi -import chat.simplex.common.views.helpers.withBGApi -import chat.simplex.common.views.usersettings.showInDevelopingAlert import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes @@ -26,10 +24,6 @@ class CallManager(val chatModel: ChatModel) { } fun acceptIncomingCall(invitation: RcvCallInvitation) { - if (appPlatform.isDesktop) { - return showInDevelopingAlert() - } - val call = chatModel.activeCall.value if (call == null) { justAcceptIncomingCall(invitation = invitation) @@ -58,12 +52,12 @@ class CallManager(val chatModel: ChatModel) { val useRelay = controller.appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, "answerIncomingCall iceServers: $iceServers") - callCommand.value = WCallCommand.Start( + callCommand.add(WCallCommand.Start( media = invitation.callType.media, aesKey = invitation.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null @@ -80,7 +74,7 @@ class CallManager(val chatModel: ChatModel) { showCallView.value = false } else { Log.d(TAG, "CallManager.endCall: ending call...") - callCommand.value = WCallCommand.End + callCommand.add(WCallCommand.End) showCallView.value = false controller.apiEndCall(call.contact) activeCall.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 3e14414fc7..4be49d4c07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -1,7 +1,5 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable -import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* import chat.simplex.res.MR @@ -23,16 +21,17 @@ data class Call( val videoEnabled: Boolean = localMedia == CallMediaType.Video, val soundSpeaker: Boolean = localMedia == CallMediaType.Video, var localCamera: VideoCamera = VideoCamera.User, - val connectionInfo: ConnectionInfo? = null + val connectionInfo: ConnectionInfo? = null, + var connectedAt: Instant? = null ) { val encrypted: Boolean get() = localEncrypted && sharedKey != null val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false - val encryptionStatus: String @Composable get() = when(callState) { + val encryptionStatus: String get() = when(callState) { CallState.WaitCapabilities -> "" - CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) - CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) - else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) + CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) + CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) + else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) } val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected @@ -49,16 +48,16 @@ enum class CallState { Connected, Ended; - val text: String @Composable get() = when(this) { - WaitCapabilities -> stringResource(MR.strings.callstate_starting) - InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer) - InvitationAccepted -> stringResource(MR.strings.callstate_starting) - OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation) - OfferReceived -> stringResource(MR.strings.callstate_received_answer) - AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation) - Negotiated -> stringResource(MR.strings.callstate_connecting) - Connected -> stringResource(MR.strings.callstate_connected) - Ended -> stringResource(MR.strings.callstate_ended) + val text: String get() = when(this) { + WaitCapabilities -> generalGetString(MR.strings.callstate_starting) + InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer) + InvitationAccepted -> generalGetString(MR.strings.callstate_starting) + OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation) + OfferReceived -> generalGetString(MR.strings.callstate_received_answer) + AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) + Negotiated -> generalGetString(MR.strings.callstate_connecting) + Connected -> generalGetString(MR.strings.callstate_connected) + Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -67,13 +66,14 @@ enum class CallState { @Serializable sealed class WCallCommand { - @Serializable @SerialName("capabilities") object Capabilities: WCallCommand() + @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand() @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() + @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @Serializable @SerialName("end") object End: WCallCommand() } @@ -85,6 +85,7 @@ sealed class WCallResponse { @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() + @Serializable @SerialName("end") object End: WCallResponse() @Serializable @SerialName("ended") object Ended: WCallResponse() @Serializable @SerialName("ok") object Ok: WCallResponse() @Serializable @SerialName("error") data class Error(val message: String): WCallResponse() @@ -106,14 +107,14 @@ sealed class WCallResponse { } @Serializable data class CallCapabilities(val encryption: Boolean) @Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { - val text: String @Composable get() { + val text: String get() { val local = localCandidate?.candidateType val remote = remoteCandidate?.candidateType return when { local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host -> - stringResource(MR.strings.call_connection_peer_to_peer) + generalGetString(MR.strings.call_connection_peer_to_peer) local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay -> - stringResource(MR.strings.call_connection_via_relay) + generalGetString(MR.strings.call_connection_via_relay) else -> "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 4afcdacbd8..40f2b32e58 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -33,7 +33,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -274,23 +273,24 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withApi { chatModel.controller.apiJoinGroup(groupId) } }, startCall = out@ { media -> - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() - } withBGApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.showCallView.value = true - chatModel.callCommand.value = WCallCommand.Capabilities + chatModel.callCommand.add(WCallCommand.Capabilities(media)) } } }, + endCall = { + val call = chatModel.activeCall.value + if (call != null) withApi { chatModel.callManager.endCall(call) } + }, acceptCall = { contact -> hideKeyboard(view) val invitation = chatModel.callInvitations.remove(contact.id) if (invitation == null) { - AlertManager.shared.showAlertMsg("Call already ended!") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended)) } else { chatModel.callManager.acceptIncomingCall(invitation = invitation) } @@ -433,6 +433,7 @@ fun ChatLayout( cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -491,7 +492,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -520,6 +521,7 @@ fun ChatInfoToolbar( back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, @@ -540,6 +542,7 @@ fun ChatInfoToolbar( } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>() + val activeCall by remember { chatModel.activeCall } menuItems.add { ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false @@ -548,20 +551,52 @@ fun ChatInfoToolbar( } if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) { - barButtons.add { - IconButton({ - showMenu.value = false - startCall(CallMediaType.Audio) - }, - enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { - Icon( - painterResource(MR.images.ic_call_500), - stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) + if (activeCall == null) { + barButtons.add { + IconButton( + { + showMenu.value = false + startCall(CallMediaType.Audio) + }, + enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active + ) { + Icon( + painterResource(MR.images.ic_call_500), + stringResource(MR.strings.icon_descr_more_button), + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } + } else if (activeCall?.contact?.id == chat.id) { + barButtons.add { + val call = remember { chatModel.activeCall }.value + val connectedAt = call?.connectedAt + if (connectedAt != null) { + val time = remember { mutableStateOf(durationText(0)) } + LaunchedEffect(Unit) { + while (true) { + time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) + delay(250) + } + } + val sp50 = with(LocalDensity.current) { 50.sp.toDp() } + Text(time.value, Modifier.widthIn(min = sp50)) + } + } + barButtons.add { + IconButton({ + showMenu.value = false + endCall() + }) { + Icon( + painterResource(MR.images.ic_call_end_filled), + null, + tint = MaterialTheme.colors.error + ) + } } } - if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { + if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active && activeCall == null) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { showMenu.value = false @@ -1290,6 +1325,7 @@ fun PreviewChatLayout() { cancelFile = {}, joinGroup = {}, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, @@ -1359,6 +1395,7 @@ fun PreviewGroupChatLayout() { cancelFile = {}, joinGroup = {}, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e281c57622..e486aca4a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -3,7 +3,6 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer import SectionDividerSpaced -import SectionItemView import SectionSpacer import SectionTextFooter import SectionView @@ -35,7 +34,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.common.views.chatlist.openChat +import chat.simplex.common.views.chatlist.openLoadedChat import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -87,7 +86,7 @@ fun GroupMemberInfoView( if (memberContact != null) { val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf()) chatModel.addChat(memberChat) - openChat(memberChat, chatModel) + openLoadedChat(memberChat, chatModel) closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 3e4dce7f4a..6f165515ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -68,7 +68,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } @@ -141,7 +141,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index b16bf4c9ba..2ff33ead57 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -4,7 +4,6 @@ import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity @@ -13,10 +12,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.ui.graphics.drawscope.ContentDrawScope -import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -126,14 +121,14 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) } } suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) } } @@ -141,12 +136,12 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") } } -suspend fun openChat(chat: Chat, chatModel: ChatModel) { +fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(chat.chatItems) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 66ef1cf9f9..4df62e12e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString @@ -29,6 +30,9 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.common.platform.* +import chat.simplex.common.views.call.Call +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -121,6 +125,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } } if (searchInList.isEmpty()) { + DesktopActiveCallOverlayLayout(newChatSheetState) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { @@ -311,6 +316,9 @@ private fun ProgressIndicator() { ) } +@Composable +expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) + fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index d8466e9d96..35d5b8b3ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -50,11 +50,12 @@ class AlertManager { fun showAlertDialogButtonsColumn( title: String, text: AnnotatedString? = null, + onDismissRequest: (() -> Unit)? = null, buttons: @Composable () -> Unit, ) { showAlert { AlertDialog( - onDismissRequest = ::hideAlert, + onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = { Text( title, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 3a799eddf8..abc894942f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -81,6 +81,35 @@ fun ProfileImage( } } +/** [AccountCircleFilled] has its inner padding which leads to visible border if there is background underneath. + * This is workaround + * */ +@Composable +fun ProfileImageForActiveCall( + size: Dp, + image: String? = null, + color: Color = MaterialTheme.colors.secondaryVariant, +) { + if (image == null) { + Box(Modifier.requiredSize(size).clip(CircleShape)) { + Icon( + AccountCircleFilled, + contentDescription = stringResource(MR.strings.icon_descr_profile_image_placeholder), + tint = color, + modifier = Modifier.requiredSize(size + 14.dp) + ) + } + } else { + val imageBitmap = base64ToBitmap(image) + Image( + imageBitmap, + stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape) + ) + } +} + @Preview @Composable diff --git a/apps/multiplatform/android/src/main/assets/www/README.md b/apps/multiplatform/common/src/commonMain/resources/assets/www/README.md similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/README.md rename to apps/multiplatform/common/src/commonMain/resources/assets/www/README.md diff --git a/packages/simplex-chat-webrtc/src/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html similarity index 88% rename from packages/simplex-chat-webrtc/src/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html index d7b3f6cefc..46910bfaf1 100644 --- a/packages/simplex-chat-webrtc/src/call.html +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html @@ -3,7 +3,7 @@ - +
- +
diff --git a/apps/multiplatform/android/src/main/assets/www/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css diff --git a/apps/multiplatform/android/src/main/assets/www/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.html similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.html diff --git a/apps/multiplatform/android/src/main/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js similarity index 92% rename from apps/multiplatform/android/src/main/assets/www/call.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index c7cf4a9324..fd574d0225 100644 --- a/apps/multiplatform/android/src/main/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -23,6 +23,9 @@ var TransformOperation; })(TransformOperation || (TransformOperation = {})); let activeCall; let answerTimeout = 30000; +var useWorker = false; +var localizedState = ""; +var localizedDescription = ""; const processCommand = (function () { const defaultIceServers = [ { urls: ["stun:stun.simplex.im:443"] }, @@ -38,9 +41,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, }; } @@ -81,6 +84,10 @@ const processCommand = (function () { if (delay) clearTimeout(delay); resolved = true; + console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)); + //const ipv6Elem = candidates.find((item) => item.candidate.includes("raddr ::")) + //candidates = ipv6Elem != undefined ? candidates.filter((elem) => elem == ipv6Elem) : candidates + //console.log("LALAL resolveIceCandidates2", JSON.stringify(candidates)) const iceCandidates = serialize(candidates); candidates = []; resolve(iceCandidates); @@ -88,19 +95,20 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return; + console.log("LALAL sendIceCandidates", JSON.stringify(candidates)); const iceCandidates = serialize(candidates); candidates = []; sendMessageToNative({ resp: { type: "ice", iceCandidates } }); } }); } - async function initializeCall(config, mediaType, aesKey, useWorker) { + async function initializeCall(config, mediaType, aesKey) { const pc = new RTCPeerConnection(config.peerConnectionConfig); const remoteStream = new MediaStream(); const localCamera = VideoCamera.User; const localStream = await getLocalMediaStream(mediaType, localCamera); const iceCandidates = getIceCandidates(pc, config); - const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker }; + const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey }; await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); pc.addEventListener("connectionstatechange", connectionStateChange); @@ -178,17 +186,17 @@ const processCommand = (function () { // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User); - const encryption = supportsInsertableStreams(command.useWorker); + const encryption = supportsInsertableStreams(useWorker); resp = { type: "capabilities", capabilities: { encryption } }; break; case "start": { console.log("starting incoming call - create webrtc session"); if (activeCall) endCall(); - const { media, useWorker, iceServers, relay } = command; + const { media, iceServers, relay } = command; const encryption = supportsInsertableStreams(useWorker); const aesKey = encryption ? command.aesKey : undefined; - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker); + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -202,7 +210,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -210,21 +217,23 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: { encryption }, }; + console.log("LALALs", JSON.stringify(resp)); break; } case "offer": if (activeCall) { resp = { type: "error", message: "accept: call already started" }; } - else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = { type: "error", message: "accept: encryption is not supported" }; } else { const offer = parse(command.offer); const remoteIceCandidates = parse(command.iceCandidates); - const { media, aesKey, useWorker, iceServers, relay } = command; - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker); + const { media, aesKey, iceServers, relay } = command; + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; + console.log("LALALo", JSON.stringify(remoteIceCandidates)); await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); @@ -236,6 +245,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, }; } + console.log("LALALo", JSON.stringify(resp)); break; case "answer": if (!pc) { @@ -250,6 +260,7 @@ const processCommand = (function () { else { const answer = parse(command.answer); const remoteIceCandidates = parse(command.iceCandidates); + console.log("LALALa", JSON.stringify(remoteIceCandidates)); await pc.setRemoteDescription(new RTCSessionDescription(answer)); addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; @@ -286,6 +297,11 @@ const processCommand = (function () { resp = { type: "ok" }; } break; + case "description": + localizedState = command.state; + localizedDescription = command.description; + resp = { type: "ok" }; + break; case "end": endCall(); resp = { type: "ok" }; @@ -310,12 +326,14 @@ const processCommand = (function () { catch (e) { console.log(e); } + shutdownCameraAndMic(); activeCall = undefined; resetVideoElements(); } function addIceCandidates(conn, iceCandidates) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)); + console.log("LALAL addIceCandidates", JSON.stringify(c)); } } async function setupMediaStreams(call) { @@ -335,7 +353,7 @@ const processCommand = (function () { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey); - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); @@ -479,6 +497,11 @@ const processCommand = (function () { return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) || (!!useWorker && "RTCRtpScriptTransform" in window)); } + function shutdownCameraAndMic() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()); + } + } function resetVideoElements() { const videos = getVideoElements(); if (!videos) @@ -507,6 +530,15 @@ const processCommand = (function () { } return processCommand; })(); +function toggleMedia(s, media) { + let res = false; + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); + for (const t of tracks) { + t.enabled = !t.enabled; + res = t.enabled; + } + return res; +} // Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) function callCryptoFunction() { const initialPlainTextRequired = { diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html new file mode 100644 index 0000000000..5e945ffe64 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg new file mode 100644 index 0000000000..34c409818a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg new file mode 100644 index 0000000000..afebf258d3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg new file mode 100644 index 0000000000..941dc182a0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 0000000000..43cfd7cb9f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg new file mode 100644 index 0000000000..def80d4719 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg new file mode 100644 index 0000000000..07557e277e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg new file mode 100644 index 0000000000..19999e82af --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg new file mode 100644 index 0000000000..2857a913f9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css new file mode 100644 index 0000000000..24c31fa6f7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js new file mode 100644 index 0000000000..73c33ae911 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -0,0 +1,80 @@ +"use strict"; +// Override defaults to enable worker on Chrome and Safari +useWorker = window.safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1; +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`); +socket.addEventListener("open", (_event) => { + console.log("Opened socket"); + sendMessageToNative = (msg) => { + console.log("Message to server: ", msg); + socket.send(JSON.stringify(msg)); + }; +}); +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data); + reactOnMessageFromServer(parsed); + processCommand(parsed); + console.log("Message from server: ", event.data); +}); +socket.addEventListener("close", (_event) => { + console.log("Closed socket"); + sendMessageToNative = (_msg) => { + console.log("Tried to send message to native but the socket was closed already"); + }; + window.close(); +}); +function endCallManually() { + sendMessageToNative({ resp: { type: "end" } }); +} +function toggleAudioManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-audio").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleSpeakerManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) { + document.getElementById("toggle-speaker").innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleVideoManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-video").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : ''; + } +} +function reactOnMessageFromServer(msg) { + var _a; + switch ((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) { + case "capabilities": + document.getElementById("info-block").className = msg.command.media; + break; + case "offer": + case "start": + document.getElementById("toggle-audio").style.display = "inline-block"; + document.getElementById("toggle-speaker").style.display = "inline-block"; + if (msg.command.media == "video") { + document.getElementById("toggle-video").style.display = "inline-block"; + } + document.getElementById("info-block").className = msg.command.media; + break; + case "description": + updateCallInfoView(msg.command.state, msg.command.description); + if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection.connectionState) == "connected") { + document.getElementById("progress").style.display = "none"; + if (document.getElementById("info-block").className == CallMediaType.Audio) { + document.getElementById("audio-call-icon").style.display = "block"; + } + } + break; + } +} +function updateCallInfoView(state, description) { + document.getElementById("state").innerText = state; + document.getElementById("description").innerText = description; +} +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/android/src/main/assets/www/lz-string.min.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/lz-string.min.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/src/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/style.css similarity index 100% rename from packages/simplex-chat-webrtc/src/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/style.css diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 4439680c66..25fc9ec8d5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -1,16 +1,15 @@ package chat.simplex.common.platform -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.* import chat.simplex.common.model.* -import chat.simplex.common.views.helpers.AlertManager -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent import java.io.File +import java.util.* import kotlin.math.max actual class RecorderNative: RecorderInterface { @@ -38,7 +37,7 @@ actual object AudioPlayer: AudioPlayerInterface { // Returns real duration of the track private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { - val absoluteFilePath = getAppFilePath(fileSource.filePath) + val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath) if (!File(absoluteFilePath).exists()) { Log.e(TAG, "No such file: ${fileSource.filePath}") return null @@ -208,6 +207,25 @@ val MediaPlayer.duration: Int get() = media().info().duration().toInt() actual object SoundPlayer: SoundPlayerInterface { - override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } - override fun stop() { /*LALAL*/ } + var playing = false + + override fun start(scope: CoroutineScope, sound: Boolean) { + withBGApi { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + playing = true + while (playing) { + if (sound) { + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + } + delay(3500) + } + } + } + + override fun stop() { + playing = false + AudioPlayer.stop() + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index df45a8acb0..42def0c75f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -1,8 +1,243 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.nanohttpd.protocols.http.IHTTPSession +import org.nanohttpd.protocols.http.response.Response +import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse +import org.nanohttpd.protocols.http.response.Status +import org.nanohttpd.protocols.websockets.* +import java.io.IOException +import java.net.URI + +private const val SERVER_HOST = "localhost" +private const val SERVER_PORT = 50395 +val connections = ArrayList() @Composable actual fun ActiveCallView() { - // LALAL + val endCall = { + val call = chatModel.activeCall.value + if (call != null) withBGApi { chatModel.callManager.endCall(call) } + } + BackHandler(onBack = endCall) + WebRTCController(chatModel.callCommand) { apiMsg -> + Log.d(TAG, "received from WebRTCController: $apiMsg") + val call = chatModel.activeCall.value + if (call != null) { + Log.d(TAG, "has active call $call") + when (val r = apiMsg.resp) { + is WCallResponse.Capabilities -> withBGApi { + val callType = CallType(call.localMedia, r.capabilities) + chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) + } + is WCallResponse.Offer -> withBGApi { + chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) + } + is WCallResponse.Answer -> withBGApi { + chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + } + is WCallResponse.Ice -> withBGApi { + chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + } + is WCallResponse.Connection -> + try { + val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") + if (callStatus == WebRTCCallStatus.Connected) { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + } catch (e: Error) { + Log.d(TAG, "call status ${r.state.connectionState} not used") + } + is WCallResponse.Connected -> { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) + } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } + is WCallResponse.Ended -> { + chatModel.activeCall.value = call.copy(callState = CallState.Ended) + withBGApi { chatModel.callManager.endCall(call) } + chatModel.showCallView.value = false + } + is WCallResponse.Ok -> when (val cmd = apiMsg.command) { + is WCallCommand.Answer -> + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + is WCallCommand.Media -> { + when (cmd.media) { + CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable) + CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable) + } + } + is WCallCommand.Camera -> { + chatModel.activeCall.value = call.copy(localCamera = cmd.camera) + if (!call.audioEnabled) { + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) + } + } + is WCallCommand.End -> + chatModel.showCallView.value = false + else -> {} + } + is WCallResponse.Error -> { + Log.e(TAG, "ActiveCallView: command error ${r.message}") + } + } + } + } + + SendStateUpdates() + DisposableEffect(Unit) { + chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } + onDispose { + chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() + } + } +} + +@Composable +private fun SendStateUpdates() { + LaunchedEffect(Unit) { + snapshotFlow { chatModel.activeCall.value } + .distinctUntilChanged() + .filterNotNull() + .collect { call -> + val state = call.callState.text + val connInfo = call.connectionInfo + // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" + val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" + val description = call.encryptionStatus + connInfoText + chatModel.callCommand.add(WCallCommand.Description(state, description)) + } + } +} + +@Composable +fun WebRTCController(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { + val uriHandler = LocalUriHandler.current + val server = remember { + uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") + startServer(onResponse) + } + fun processCommand(cmd: WCallCommand) { + val apiCall = WVAPICall(command = cmd) + for (connection in connections.toList()) { + try { + connection.send(json.encodeToString(apiCall)) + break + } catch (e: Exception) { + Log.e(TAG, "Failed to send message to browser: ${e.stackTraceToString()}") + } + } + } + DisposableEffect(Unit) { + onDispose { + processCommand(WCallCommand.End) + server.stop() + connections.clear() + } + } + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd") + processCommand(cmd) + } + } + } +} + +fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { + override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) + + @Suppress("NewApi") + fun resourcesToResponse(path: String): Response { + val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound + val response = newFixedLengthResponse( + Status.OK, getMimeTypeForFile(uri.file), + uri.openStream().readAllBytes() + ) + response.setKeepAlive(true) + response.setUseGzip(true) + return response + } + + val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found") + + override fun handle(session: IHTTPSession): Response { + return when { + session.headers["upgrade"] == "websocket" -> super.handle(session) + session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") + else -> resourcesToResponse(URI.create(session.uri).path) + } + } + } + server.start(60_000_000) + return server +} + +class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) { + override fun onOpen() { + connections.add(this) + } + + override fun onClose(closeCode: CloseCode?, reason: String?, initiatedByRemote: Boolean) { + onResponse(WVAPIMessage(null, WCallResponse.End)) + } + + override fun onMessage(message: WebSocketFrame) { + Log.d(TAG, "MyWebSocket.onMessage") + try { + // for debugging + // onResponse(message.textPayload) + onResponse(json.decodeFromString(message.textPayload)) + } catch (e: Exception) { + Log.e(TAG, "failed parsing browser message: $message") + } + } + + override fun onPong(pong: WebSocketFrame?) = Unit + + override fun onException(exception: IOException) { + Log.e(TAG, "WebSocket exception: ${exception.stackTraceToString()}") + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 0ad69d1c04..2b646f0e46 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.unit.dp import chat.simplex.common.views.helpers.* -private object NoIndication : Indication { +object NoIndication : Indication { private object NoIndicationInstance : IndicationInstance { override fun ContentDrawScope.drawIndication() { drawContent() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt new file mode 100644 index 0000000000..d2fa97f860 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -0,0 +1,75 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) { + val call = remember { chatModel.activeCall}.value + // if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { + if (call != null && !newChatSheetState.collectAsState().value.isVisible()) { + val showMenu = remember { mutableStateOf(false) } + val media = call.peerMedia ?: call.localMedia + CompositionLocalProvider( + LocalIndication provides NoIndication + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.BottomEnd + ) { + Box( + Modifier + .padding(end = 71.dp, bottom = 92.dp) + .size(67.dp) + .combinedClickable(onClick = { + val chat = chatModel.getChat(call.contact.id) + if (chat != null) { + withApi { + openChat(chat.chatInfo, chatModel) + } + } + }, + onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { + ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) + } + Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) { + if (media == CallMediaType.Video) { + Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White) + } else { + Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White) + } + } + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = { + withBGApi { chatModel.callManager.endCall(call) } + showMenu.value = false + }) + } + } + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..958327733694183c605a93a07096194633869ed8 GIT binary patch literal 72269 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 literal 0 HcmV?d00001 diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy index 4991cdef45..770547b2cd 100755 --- a/packages/simplex-chat-webrtc/copy +++ b/packages/simplex-chat-webrtc/copy @@ -1,14 +1,24 @@ #!/bin/sh # it can be tested in the browser from dist folder -cp ./src/call.html ./dist/call.html -cp ./src/style.css ./dist/style.css +mkdir -p dist/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ./dist/android/call.html +cp ./src/android/style.css ./dist/android/style.css +cp ./src/desktop/call.html ./dist/desktop/call.html +cp ./src/desktop/style.css ./dist/desktop/style.css +cp ./src/desktop/images/* ./dist/desktop/images/ cp ./node_modules/lz-string/libs/lz-string.min.js ./dist/lz-string.min.js cp ./src/webcall.html ./dist/webcall.html cp ./src/ui.js ./dist/ui.js -# copy to android app -cp ./src/call.html ../../apps/multiplatform/android/src/main/assets/www/call.html -cp ./src/style.css ../../apps/multiplatform/android/src/main/assets/www/style.css -cp ./dist/call.js ../../apps/multiplatform/android/src/main/assets/www/call.js -cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/android/src/main/assets/www/lz-string.min.js +# copy to android and desktop apps +mkdir -p ../../apps/multiplatform/common/src/commonMain/resources/assets/www/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html +cp ./src/android/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css +cp ./src/desktop/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html +cp ./src/desktop/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css +cp ./src/desktop/images/* ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ + +cp ./dist/desktop/ui.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +cp ./dist/call.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index d1fc60b5a4..f11ea36343 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -40,4 +40,4 @@ "dependencies": { "lz-string": "^1.4.4" } -} \ No newline at end of file +} diff --git a/packages/simplex-chat-webrtc/src/android/call.html b/packages/simplex-chat-webrtc/src/android/call.html new file mode 100644 index 0000000000..46910bfaf1 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/call.html @@ -0,0 +1,26 @@ + + + + + + + + + + + +
+ +
+ diff --git a/packages/simplex-chat-webrtc/src/android/style.css b/packages/simplex-chat-webrtc/src/android/style.css new file mode 100644 index 0000000000..3d2941c71e --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/style.css @@ -0,0 +1,41 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 7b0b51ea6d..a6f036eb30 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -15,6 +15,7 @@ type WCallCommand = | WCallIceCandidates | WCEnableMedia | WCToggleCamera + | WCDescription | WCEndCall type WCallResponse = @@ -24,14 +25,15 @@ type WCallResponse = | WCallIceCandidates | WRConnection | WRCallConnected + | WRCallEnd | WRCallEnded | WROk | WRError | WCAcceptOffer -type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "end" +type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end" -type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "ended" | "ok" | "error" +type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error" enum CallMediaType { Audio = "audio", @@ -53,15 +55,13 @@ interface IWCallResponse { interface WCCapabilities extends IWCallCommand { type: "capabilities" - media?: CallMediaType - useWorker?: boolean + media: CallMediaType } interface WCStartCall extends IWCallCommand { type: "start" media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -76,7 +76,6 @@ interface WCAcceptOffer extends IWCallCommand { iceCandidates: string // JSON strings for RTCIceCandidateInit media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -110,6 +109,12 @@ interface WCToggleCamera extends IWCallCommand { camera: VideoCamera } +interface WCDescription extends IWCallCommand { + type: "description" + state: string + description: string +} + interface WRCapabilities extends IWCallResponse { type: "capabilities" capabilities: CallCapabilities @@ -134,6 +139,10 @@ interface WRCallConnected extends IWCallResponse { connectionInfo: ConnectionInfo } +interface WRCallEnd extends IWCallResponse { + type: "end" +} + interface WRCallEnded extends IWCallResponse { type: "ended" } @@ -185,13 +194,15 @@ interface Call { localStream: MediaStream remoteStream: MediaStream aesKey?: string - useWorker?: boolean worker?: Worker key?: CryptoKey } let activeCall: Call | undefined let answerTimeout = 30_000 +var useWorker = false +var localizedState = "" +var localizedDescription = "" const processCommand = (function () { type RTCRtpSenderWithEncryption = RTCRtpSender & { @@ -232,9 +243,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, } } @@ -274,6 +285,7 @@ const processCommand = (function () { function resolveIceCandidates() { if (delay) clearTimeout(delay) resolved = true + console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)) const iceCandidates = serialize(candidates) candidates = [] resolve(iceCandidates) @@ -281,6 +293,7 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return + console.log("LALAL sendIceCandidates", JSON.stringify(candidates)) const iceCandidates = serialize(candidates) candidates = [] sendMessageToNative({resp: {type: "ice", iceCandidates}}) @@ -288,13 +301,13 @@ const processCommand = (function () { }) } - async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string, useWorker?: boolean): Promise { + async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { const pc = new RTCPeerConnection(config.peerConnectionConfig) const remoteStream = new MediaStream() const localCamera = VideoCamera.User const localStream = await getLocalMediaStream(mediaType, localCamera) const iceCandidates = getIceCandidates(pc, config) - const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker} + const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey} await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) pc.addEventListener("connectionstatechange", connectionStateChange) @@ -374,16 +387,16 @@ const processCommand = (function () { if (activeCall) endCall() // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User) - const encryption = supportsInsertableStreams(command.useWorker) + const encryption = supportsInsertableStreams(useWorker) resp = {type: "capabilities", capabilities: {encryption}} break case "start": { console.log("starting incoming call - create webrtc session") if (activeCall) endCall() - const {media, useWorker, iceServers, relay} = command + const {media, iceServers, relay} = command const encryption = supportsInsertableStreams(useWorker) const aesKey = encryption ? command.aesKey : undefined - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker) + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection const offer = await pc.createOffer() await pc.setLocalDescription(offer) @@ -397,7 +410,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -405,19 +417,21 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: {encryption}, } + console.log("LALALs", JSON.stringify(resp)) break } case "offer": if (activeCall) { resp = {type: "error", message: "accept: call already started"} - } else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + } else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = {type: "error", message: "accept: encryption is not supported"} } else { const offer: RTCSessionDescriptionInit = parse(command.offer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - const {media, aesKey, useWorker, iceServers, relay} = command - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker) + const {media, aesKey, iceServers, relay} = command + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection + console.log("LALALo", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) @@ -429,6 +443,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, } } + console.log("LALALo", JSON.stringify(resp)) break case "answer": if (!pc) { @@ -440,6 +455,7 @@ const processCommand = (function () { } else { const answer: RTCSessionDescriptionInit = parse(command.answer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) + console.log("LALALa", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)) addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} @@ -472,6 +488,11 @@ const processCommand = (function () { resp = {type: "ok"} } break + case "description": + localizedState = command.state + localizedDescription = command.description + resp = {type: "ok"} + break case "end": endCall() resp = {type: "ok"} @@ -494,6 +515,7 @@ const processCommand = (function () { } catch (e) { console.log(e) } + shutdownCameraAndMic() activeCall = undefined resetVideoElements() } @@ -501,6 +523,7 @@ const processCommand = (function () { function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)) + console.log("LALAL addIceCandidates", JSON.stringify(c)) } } @@ -520,7 +543,7 @@ const processCommand = (function () { async function setupEncryptionWorker(call: Call) { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey) - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => @@ -680,6 +703,12 @@ const processCommand = (function () { remote: HTMLMediaElement } + function shutdownCameraAndMic() { + if (activeCall?.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()) + } + } + function resetVideoElements() { const videos = getVideoElements() if (!videos) return @@ -706,10 +735,19 @@ const processCommand = (function () { const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() for (const t of tracks) t.enabled = enable } - return processCommand })() +function toggleMedia(s: MediaStream, media: CallMediaType): boolean { + let res = false + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() + for (const t of tracks) { + t.enabled = !t.enabled + res = t.enabled + } + return res +} + type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise interface CallCrypto { diff --git a/packages/simplex-chat-webrtc/src/desktop/call.html b/packages/simplex-chat-webrtc/src/desktop/call.html new file mode 100644 index 0000000000..5e945ffe64 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg new file mode 100644 index 0000000000..34c409818a --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg new file mode 100644 index 0000000000..afebf258d3 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg new file mode 100644 index 0000000000..941dc182a0 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 0000000000..43cfd7cb9f --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg new file mode 100644 index 0000000000..def80d4719 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg new file mode 100644 index 0000000000..07557e277e --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg new file mode 100644 index 0000000000..19999e82af --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg new file mode 100644 index 0000000000..2857a913f9 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/style.css b/packages/simplex-chat-webrtc/src/desktop/style.css new file mode 100644 index 0000000000..24c31fa6f7 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts new file mode 100644 index 0000000000..ea681b4ebd --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -0,0 +1,87 @@ +// Override defaults to enable worker on Chrome and Safari +useWorker = (window as any).safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1 + +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`) + +socket.addEventListener("open", (_event) => { + console.log("Opened socket") + sendMessageToNative = (msg: WVApiMessage) => { + console.log("Message to server: ", msg) + socket.send(JSON.stringify(msg)) + } +}) + +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data) + reactOnMessageFromServer(parsed) + processCommand(parsed) + console.log("Message from server: ", event.data) +}) + +socket.addEventListener("close", (_event) => { + console.log("Closed socket") + sendMessageToNative = (_msg: WVApiMessage) => { + console.log("Tried to send message to native but the socket was closed already") + } + window.close() +}) + +function endCallManually() { + sendMessageToNative({resp: {type: "end"}}) +} + +function toggleAudioManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-audio")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleSpeakerManually() { + if (activeCall?.remoteStream) { + document.getElementById("toggle-speaker")!!.innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleVideoManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-video")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : '' + } +} + +function reactOnMessageFromServer(msg: WVApiMessage) { + switch (msg.command?.type) { + case "capabilities": + document.getElementById("info-block")!!.className = msg.command.media + break + case "offer": + case "start": + document.getElementById("toggle-audio")!!.style.display = "inline-block" + document.getElementById("toggle-speaker")!!.style.display = "inline-block" + if (msg.command.media == "video") { + document.getElementById("toggle-video")!!.style.display = "inline-block" + } + document.getElementById("info-block")!!.className = msg.command.media + break + case "description": + updateCallInfoView(msg.command.state, msg.command.description) + if (activeCall?.connection.connectionState == "connected") { + document.getElementById("progress")!.style.display = "none" + if (document.getElementById("info-block")!!.className == CallMediaType.Audio) { + document.getElementById("audio-call-icon")!.style.display = "block" + } + } + break + } +} + +function updateCallInfoView(state: string, description: string) { + document.getElementById("state")!!.innerText = state + document.getElementById("description")!!.innerText = description +} From f026a38a75ebedb209f28f276cae78af98942cc6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 19:22:46 +0100 Subject: [PATCH 53/80] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 54 ++++++++-------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2a90e75d85..92b55fd53e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -16,7 +16,6 @@ 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; }; 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; }; 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; }; - 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; @@ -85,11 +84,6 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; }; - 5CA8D0162AD746C8001FD661 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0112AD746C8001FD661 /* libgmpxx.a */; }; - 5CA8D0172AD746C8001FD661 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0122AD746C8001FD661 /* libffi.a */; }; - 5CA8D0182AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; - 5CA8D0192AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; - 5CA8D01A2AD746C8001FD661 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA8D0152AD746C8001FD661 /* libgmp.a */; }; 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; }; 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; @@ -123,6 +117,11 @@ 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; + 5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */; }; + 5CD089322AE59CB300669208 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892D2AE59CB300669208 /* libffi.a */; }; + 5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892E2AE59CB300669208 /* libgmpxx.a */; }; + 5CD089342AE59CB300669208 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892F2AE59CB300669208 /* libgmp.a */; }; + 5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -176,11 +175,6 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */; }; - 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */; }; - 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */; }; - 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C812AD6B6B900B21C4C /* libffi.a */; }; - 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; @@ -262,7 +256,6 @@ 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = ""; }; 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = ""; }; - 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../multiplatform/android/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; @@ -363,11 +356,6 @@ 5CA85D0A297218AA0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 5CA85D0C297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = "it.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CA85D0D297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 5CA8D0112AD746C8001FD661 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CA8D0122AD746C8001FD661 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CA8D0132AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; - 5CA8D0142AD746C8001FD661 /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; - 5CA8D0152AD746C8001FD661 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5CAB912529E93F9400F34A95 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 5CAC41182A192D8400C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 5CAC411A2A192DE800C331A2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = "ja.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -409,6 +397,11 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a"; sourceTree = ""; }; + 5CD0892D2AE59CB300669208 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CD0892E2AE59CB300669208 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CD0892F2AE59CB300669208 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a"; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -463,11 +456,6 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; - 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a"; sourceTree = ""; }; - 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a"; sourceTree = ""; }; - 64AB9C812AD6B6B900B21C4C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; @@ -517,13 +505,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 64AB9C842AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a in Frameworks */, - 64AB9C862AD6B6B900B21C4C /* libffi.a in Frameworks */, - 64AB9C852AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a in Frameworks */, + 5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 64AB9C832AD6B6B900B21C4C /* libgmp.a in Frameworks */, + 5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 64AB9C872AD6B6B900B21C4C /* libgmpxx.a in Frameworks */, + 5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */, + 5CD089342AE59CB300669208 /* libgmp.a in Frameworks */, + 5CD089322AE59CB300669208 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -584,11 +572,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 64AB9C812AD6B6B900B21C4C /* libffi.a */, - 64AB9C7E2AD6B6B900B21C4C /* libgmp.a */, - 64AB9C822AD6B6B900B21C4C /* libgmpxx.a */, - 64AB9C7F2AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b-ghc8.10.7.a */, - 64AB9C802AD6B6B900B21C4C /* libHSsimplex-chat-5.4.0.1-7lTZkX9ojv2DbehL2eOY1b.a */, + 5CD0892D2AE59CB300669208 /* libffi.a */, + 5CD0892F2AE59CB300669208 /* libgmp.a */, + 5CD0892E2AE59CB300669208 /* libgmpxx.a */, + 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */, + 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */, ); path = Libraries; sourceTree = ""; @@ -648,7 +636,6 @@ isa = PBXGroup; children = ( 5C55A92D283D0FDE00C4E99E /* sounds */, - 3C714779281C0F6800CB4D4B /* www */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, @@ -1060,7 +1047,6 @@ buildActionMask = 2147483647; files = ( 5C55A92E283D0FDE00C4E99E /* sounds in Resources */, - 3C71477A281C0F6800CB4D4B /* www in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, From 795c54343a02d9b50a4a9e15a6cdf05ae70e74c5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 20:51:08 +0100 Subject: [PATCH 54/80] android, desktop: reduce browser call logs, check Worker availability directly (#3256) --- .../commonMain/resources/assets/www/call.js | 23 +++++++++---------- .../resources/assets/www/desktop/ui.js | 6 ++--- packages/simplex-chat-webrtc/src/call.ts | 21 +++++++++-------- .../simplex-chat-webrtc/src/desktop/ui.ts | 6 ++--- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index fd574d0225..62c9ca7fbe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -84,10 +84,8 @@ const processCommand = (function () { if (delay) clearTimeout(delay); resolved = true; - console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)); - //const ipv6Elem = candidates.find((item) => item.candidate.includes("raddr ::")) - //candidates = ipv6Elem != undefined ? candidates.filter((elem) => elem == ipv6Elem) : candidates - //console.log("LALAL resolveIceCandidates2", JSON.stringify(candidates)) + // console.log("resolveIceCandidates", JSON.stringify(candidates)) + console.log("resolveIceCandidates"); const iceCandidates = serialize(candidates); candidates = []; resolve(iceCandidates); @@ -95,7 +93,8 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return; - console.log("LALAL sendIceCandidates", JSON.stringify(candidates)); + // console.log("sendIceCandidates", JSON.stringify(candidates)) + console.log("sendIceCandidates"); const iceCandidates = serialize(candidates); candidates = []; sendMessageToNative({ resp: { type: "ice", iceCandidates } }); @@ -217,7 +216,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: { encryption }, }; - console.log("LALALs", JSON.stringify(resp)); + // console.log("offer response", JSON.stringify(resp)) break; } case "offer": @@ -233,7 +232,7 @@ const processCommand = (function () { const { media, aesKey, iceServers, relay } = command; activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; - console.log("LALALo", JSON.stringify(remoteIceCandidates)); + // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); @@ -245,7 +244,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, }; } - console.log("LALALo", JSON.stringify(resp)); + // console.log("answer response", JSON.stringify(resp)) break; case "answer": if (!pc) { @@ -260,7 +259,7 @@ const processCommand = (function () { else { const answer = parse(command.answer); const remoteIceCandidates = parse(command.iceCandidates); - console.log("LALALa", JSON.stringify(remoteIceCandidates)); + // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)); addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; @@ -333,7 +332,7 @@ const processCommand = (function () { function addIceCandidates(conn, iceCandidates) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)); - console.log("LALAL addIceCandidates", JSON.stringify(c)); + // console.log("addIceCandidates", JSON.stringify(c)) } } async function setupMediaStreams(call) { @@ -356,8 +355,8 @@ const processCommand = (function () { if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); - call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); - call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data })); + call.worker.onerror = ({ error, filename, lineno, message }) => console.log({ error, filename, lineno, message }); + // call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js index 73c33ae911..2514a24117 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -1,12 +1,12 @@ "use strict"; // Override defaults to enable worker on Chrome and Safari -useWorker = window.safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1; +useWorker = typeof window.Worker !== "undefined"; // Create WebSocket connection. const socket = new WebSocket(`ws://${location.host}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); sendMessageToNative = (msg) => { - console.log("Message to server: ", msg); + console.log("Message to server"); socket.send(JSON.stringify(msg)); }; }); @@ -14,7 +14,7 @@ socket.addEventListener("message", (event) => { const parsed = JSON.parse(event.data); reactOnMessageFromServer(parsed); processCommand(parsed); - console.log("Message from server: ", event.data); + console.log("Message from server"); }); socket.addEventListener("close", (_event) => { console.log("Closed socket"); diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index a6f036eb30..2a55b26717 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -285,7 +285,8 @@ const processCommand = (function () { function resolveIceCandidates() { if (delay) clearTimeout(delay) resolved = true - console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)) + // console.log("resolveIceCandidates", JSON.stringify(candidates)) + console.log("resolveIceCandidates") const iceCandidates = serialize(candidates) candidates = [] resolve(iceCandidates) @@ -293,7 +294,8 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return - console.log("LALAL sendIceCandidates", JSON.stringify(candidates)) + // console.log("sendIceCandidates", JSON.stringify(candidates)) + console.log("sendIceCandidates") const iceCandidates = serialize(candidates) candidates = [] sendMessageToNative({resp: {type: "ice", iceCandidates}}) @@ -417,7 +419,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: {encryption}, } - console.log("LALALs", JSON.stringify(resp)) + // console.log("offer response", JSON.stringify(resp)) break } case "offer": @@ -431,7 +433,7 @@ const processCommand = (function () { const {media, aesKey, iceServers, relay} = command activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection - console.log("LALALo", JSON.stringify(remoteIceCandidates)) + // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) @@ -443,7 +445,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, } } - console.log("LALALo", JSON.stringify(resp)) + // console.log("answer response", JSON.stringify(resp)) break case "answer": if (!pc) { @@ -455,7 +457,7 @@ const processCommand = (function () { } else { const answer: RTCSessionDescriptionInit = parse(command.answer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - console.log("LALALa", JSON.stringify(remoteIceCandidates)) + // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)) addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} @@ -523,7 +525,7 @@ const processCommand = (function () { function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)) - console.log("LALAL addIceCandidates", JSON.stringify(c)) + // console.log("addIceCandidates", JSON.stringify(c)) } } @@ -546,9 +548,8 @@ const processCommand = (function () { if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) - call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => - console.log(JSON.stringify({error, filename, lineno, message})) - call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) + call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => console.log({error, filename, lineno, message}) + // call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts index ea681b4ebd..f72adffd9f 100644 --- a/packages/simplex-chat-webrtc/src/desktop/ui.ts +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -1,5 +1,5 @@ // Override defaults to enable worker on Chrome and Safari -useWorker = (window as any).safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1 +useWorker = typeof window.Worker !== "undefined" // Create WebSocket connection. const socket = new WebSocket(`ws://${location.host}`) @@ -7,7 +7,7 @@ const socket = new WebSocket(`ws://${location.host}`) socket.addEventListener("open", (_event) => { console.log("Opened socket") sendMessageToNative = (msg: WVApiMessage) => { - console.log("Message to server: ", msg) + console.log("Message to server") socket.send(JSON.stringify(msg)) } }) @@ -16,7 +16,7 @@ socket.addEventListener("message", (event) => { const parsed = JSON.parse(event.data) reactOnMessageFromServer(parsed) processCommand(parsed) - console.log("Message from server: ", event.data) + console.log("Message from server") }) socket.addEventListener("close", (_event) => { From 0bd59364fd3431eba6913263e1222ee8b8b15206 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 22 Oct 2023 22:43:23 +0100 Subject: [PATCH 55/80] 5.4.0-beta.2: iOS 180, Android 159, Desktop 15 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 ++++++------ apps/multiplatform/gradle.properties | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 92b55fd53e..fafb43145c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1482,7 +1482,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1524,7 +1524,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1604,7 +1604,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1636,7 +1636,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1668,7 +1668,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1714,7 +1714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index e37dbca8eb..3474208140 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.0 -android.version_code=156 +android.version_name=5.4-beta.2 +android.version_code=159 -desktop.version_name=5.4-beta.0 -desktop.version_code=12 +desktop.version_name=5.4-beta.2 +desktop.version_code=15 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 From 6eb09625ab311ebb2513d9e7f98263f59a98ba91 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 23 Oct 2023 20:53:12 +0100 Subject: [PATCH 56/80] website: update copy --- website/langs/en.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/langs/en.json b/website/langs/en.json index 434aed8343..1bb64c7efa 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -37,12 +37,12 @@ "hero-overlay-2-title": "Why user IDs are bad for privacy?", "hero-overlay-3-title": "Security assessment", "feature-1-title": "E2E-encrypted messages with markdown and editing", - "feature-2-title": "E2E-encrypted
images and files", - "feature-3-title": "Decentralized secret groups —
only users know they exist", + "feature-2-title": "E2E-encrypted
images, videos and files", + "feature-3-title": "E2E-encrypted decentralized groups — only users know they exist", "feature-4-title": "E2E-encrypted voice messages", "feature-5-title": "Disappearing messages", "feature-6-title": "E2E-encrypted
audio and video calls", - "feature-7-title": "Portable encrypted database — move your profile to another device", + "feature-7-title": "Portable encrypted app storage — move profile to another device", "feature-8-title": "Incognito mode —
unique to SimpleX Chat", "simplex-network-overlay-1-title": "Comparison with P2P messaging protocols", "simplex-private-1-title": "2-layers of
end-to-end encryption", From 66d8bb94d6d18df4e42690951ff4c41c2e13128e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 23 Oct 2023 21:16:36 +0100 Subject: [PATCH 57/80] website: downloads page --- docs/DOWNLOADS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 64b76e7fec..94b8d9197e 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -7,7 +7,7 @@ revision: 01.10.2023 | Updated 01.10.2023 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.3.1. +The latest stable version is v5.3.2. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,9 +21,9 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA). @@ -31,14 +31,14 @@ Using the same profile as on mobile device is not yet supported – you need to **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-windows-x86-64). From ed1eef7362a1f21647d20628d97eab50fe928220 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:38:16 +0400 Subject: [PATCH 58/80] core: update simplexmq (inv locks) (#3274) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 8a05c0bb95..eb557e52f5 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc + tag: 55a6157880396be899c010f880a42322cf65258a source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 60b9505fea..6c41ff2a1a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc" = "0xcbvxz2nszm1sdh6gvmfzjf9n2ldsarmmzbl6j6b5hg9i1mppc6"; + "https://github.com/simplex-chat/simplexmq.git"."55a6157880396be899c010f880a42322cf65258a" = "1fhhyi2060pp72izrqki6gazb759hcv9wypxf39jkwpqpvrn81hv"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/stack.yaml b/stack.yaml index 99b9d179cd..e6f6f49b76 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: cf8b9c12ff5cbdc77d3b8866af2c761a546ec8fc + commit: 55a6157880396be899c010f880a42322cf65258a - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher From f8332bac7f624d8003b9613ef425f0cf82b2d10b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 24 Oct 2023 18:13:19 +0400 Subject: [PATCH 59/80] core: take chat lock earlier when joining group (#3272) --- src/Simplex/Chat.hs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3581b80c29..c4b34666d5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1547,12 +1547,12 @@ processChatCommand = \case Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do - (invitation, ct) <- withStore $ \db -> do - inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId - (inv,) <$> getContactViaMember db user fromMember - let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation - Contact {activeConn = Connection {peerChatVRange}} = ct withChatLock "joinGroup" . procCmd $ do + (invitation, ct) <- withStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId + (inv,) <$> getContactViaMember db user fromMember + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation + Contact {activeConn = Connection {peerChatVRange}} = ct subMode <- chatReadVar subscriptionMode dm <- directMessage $ XGrpAcpt membership.memberId agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode From 239765e482b837f8dabbd82e74735713a915ea7e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 24 Oct 2023 20:59:06 +0400 Subject: [PATCH 60/80] core: create new group with incognito membership (#3277) --- src/Simplex/Chat.hs | 14 +++++---- src/Simplex/Chat/Controller.hs | 4 +-- src/Simplex/Chat/Store/Direct.hs | 11 ------- src/Simplex/Chat/Store/Groups.hs | 7 +++-- src/Simplex/Chat/Store/Shared.hs | 11 +++++++ src/Simplex/Chat/View.hs | 25 +++++++++++----- tests/ChatTests/Groups.hs | 51 ++++++++++++++++++++++++++++++++ tests/ChatTests/Profiles.hs | 2 +- 8 files changed, 95 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c4b34666d5..c70c842c04 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1510,13 +1510,15 @@ processChatCommand = \case chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction - APINewGroup userId gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do + APINewGroup userId incognito gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do checkValidName displayName gVar <- asks idsDrg - groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile + -- [incognito] generate incognito profile for group membership + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile incognitoProfile pure $ CRGroupCreated user groupInfo - NewGroup gProfile -> withUser $ \User {userId} -> - processChatCommand $ APINewGroup userId gProfile + NewGroup incognito gProfile -> withUser $ \User {userId} -> + processChatCommand $ APINewGroup userId incognito gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId @@ -5714,8 +5716,8 @@ chatCommandP = ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, ("/help" <|> "/h") $> ChatHelp HSMain, - ("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile), - "/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP), + ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), + "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d8851ad87a..74501ad1e5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -363,8 +363,8 @@ data ChatCommand | EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text} | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: Text} | ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text} - | APINewGroup UserId GroupProfile - | NewGroup GroupProfile + | APINewGroup UserId IncognitoEnabled GroupProfile + | NewGroup IncognitoEnabled GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName | MemberRole GroupName ContactName GroupMemberRole diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 563cc337e0..477361acdf 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -193,17 +193,6 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 -createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do - DB.execute - db - [sql| - INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?) - |] - (displayName, fullName, image, userId, Just True, createdAt, createdAt) - insertedRowId db - createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do createdAt <- liftIO getCurrentTime diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 76d68cc6be..0b296b17e8 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -283,11 +283,12 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo -createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do +createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo +createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime + customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do groupId <- liftIO $ do DB.execute @@ -301,7 +302,7 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do (ldn, userId, profileId, True, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs + membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 2a90b54d7f..2ad447aa8e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -184,6 +184,17 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange where ent ct = if connType == ct then entityId else Nothing +createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 +createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do + DB.execute + db + [sql| + INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) + |] + (displayName, fullName, image, userId, Just True, createdAt, createdAt) + insertedRowId db + setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () setPeerChatVRange db connId (VersionRange minVer maxVer) = DB.execute diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 07cbec601f..7f1e1f5c7a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -132,7 +132,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept CRUserContactLinkUpdated u UserContactLink {autoAccept} -> ttyUser u $ autoAcceptStatus_ autoAccept CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] - CRGroupCreated u g -> ttyUser u $ viewGroupCreated g + CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> @@ -792,11 +792,22 @@ viewReceivedContactRequest c Profile {fullName} = "to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)" ] -viewGroupCreated :: GroupInfo -> [StyledString] -viewGroupCreated g = - [ "group " <> ttyFullGroup g <> " is created", - "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) - ] +viewGroupCreated :: GroupInfo -> Bool -> [StyledString] +viewGroupCreated g testView = + case incognitoMembershipProfile g of + Just localProfile + | testView -> incognitoProfile' profile : message + | otherwise -> message + where + profile = fromLocalProfile localProfile + message = + [ "group " <> ttyFullGroup g <> " is created, your incognito profile for this group is " <> incognitoProfile' profile, + "to add members use " <> highlight ("/create link #" <> viewGroupName g) + ] + Nothing -> + [ "group " <> ttyFullGroup g <> " is created", + "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) + ] viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation g c = @@ -1672,7 +1683,7 @@ viewChatError logLevel = \case _ -> ": you have insufficient permissions for this action, the required role is " <> plain (strEncode role) CEGroupMemberInitialRole g role -> [ttyGroup' g <> ": initial role for group member cannot be " <> plain (strEncode role) <> ", use member or observer"] CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"] - CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"] + CEGroupIncognitoCantInvite -> ["you are using an incognito profile for this group - prohibited to invite contacts"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> viewGroupName g)] CEGroupMemberNotActive -> ["your group connection is not active yet, try later"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 7a8b1368bf..97b7491067 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -23,6 +23,7 @@ chatGroupTests = do describe "chat groups" $ do it "add contacts, create group and send/receive messages" testGroup it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages + it "create group with incognito membership" testNewGroupIncognito it "create and join group with 4 members" testGroup2 it "create and delete group" testGroupDelete it "create group with the same displayName" testGroupSameName @@ -277,6 +278,56 @@ testGroupShared alice bob cath checkMessages = do alice #$> ("/_unread chat #1 on", id, "ok") alice #$> ("/_unread chat #1 off", id, "ok") +testNewGroupIncognito :: HasCallStack => FilePath -> IO () +testNewGroupIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + -- alice creates group with incognito membership + alice ##> "/g i team" + aliceIncognito <- getTermLine alice + alice <## ("group #team is created, your incognito profile for this group is " <> aliceIncognito) + alice <## "to add members use /create link #team" + + -- alice invites bob + alice ##> "/a team bob" + alice <## "you are using an incognito profile for this group - prohibited to invite contacts" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + _ <- getTermLine alice + concurrentlyN_ + [ do + alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob_1 to print out this incognito profile again" + alice <## "bob_1 invited to group #team via your group link" + alice <## "#team: bob_1 joined the group", + do + bob <## (aliceIncognito <> ": contact is connected") + bob <## "#team: you joined the group" + ] + + alice <##> bob + + alice ?#> "@bob_1 hi, I'm incognito" + bob <# (aliceIncognito <> "> hi, I'm incognito") + bob #> ("@" <> aliceIncognito <> " hey, I'm bob") + alice ?<# "bob_1> hey, I'm bob" + + alice ?#> "#team hello" + bob <# ("#team " <> aliceIncognito <> "> hello") + bob #> "#team hi there" + alice ?<# "#team bob_1> hi there" + + alice ##> "/gs" + alice <## "i #team (2 members)" + bob ##> "/gs" + bob <## "#team (2 members)" + testGroup2 :: HasCallStack => FilePath -> IO () testGroup2 = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 68b925342e..d806290d6e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1085,7 +1085,7 @@ testJoinGroupIncognito = ] -- cath cannot invite to the group because her membership is incognito cath ##> "/a secret_club dan" - cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" + cath <## "you are using an incognito profile for this group - prohibited to invite contacts" -- alice invites dan alice ##> "/a secret_club dan admin" concurrentlyN_ From b58d61c3397f669bde0e0f34e482227ca425f0f3 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 04:27:58 +0800 Subject: [PATCH 61/80] android: delete files after sharing correctly (#3264) --- .../simplex/common/views/chat/ComposeView.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 972bc6621f..84a5879b2b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -324,8 +324,26 @@ fun ComposeView( } fun deleteUnusedFiles() { - chatModel.filesToDelete.forEach { it.delete() } - chatModel.filesToDelete.clear() + val shared = chatModel.sharedContent.value + if (shared == null) { + chatModel.filesToDelete.forEach { it.delete() } + chatModel.filesToDelete.clear() + } else { + val sharedPaths = when (shared) { + is SharedContent.Media -> shared.uris.map { it.toString() } + is SharedContent.File -> listOf(shared.uri.toString()) + is SharedContent.Text -> emptyList() + } + // When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing + chatModel.filesToDelete.removeAll { file -> + if (sharedPaths.any { it.endsWith(file.name) }) { + false + } else { + file.delete() + true + } + } + } } suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { From 10f79aae6608a72b285f9cbdb121104aed60b48a Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 04:39:43 +0800 Subject: [PATCH 62/80] android: alert on unsupported file path when sharing (#3265) * android: alert on unsupported file path when sharing * update text --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../src/main/java/chat/simplex/app/MainActivity.kt | 11 +++++++++++ .../src/commonMain/resources/MR/base/strings.xml | 2 ++ 2 files changed, 13 insertions(+) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 2dff3d604b..19305c2e51 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -143,6 +143,7 @@ fun processExternalIntent(intent: Intent?) { val text = intent.getStringExtra(Intent.EXTRA_TEXT) val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() // Shared file that contains plain text, like `*.log` file chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI()) } else if (text != null) { @@ -153,12 +154,14 @@ fun processExternalIntent(intent: Intent?) { isMediaIntent(intent) -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI())) } // All other mime types } else -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI()) } } @@ -173,6 +176,7 @@ fun processExternalIntent(intent: Intent?) { isMediaIntent(intent) -> { val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) as? List if (uris != null) { + if (uris.any { it.scheme != "content" }) return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() }) } // All other mime types } @@ -185,6 +189,13 @@ fun processExternalIntent(intent: Intent?) { fun isMediaIntent(intent: Intent): Boolean = intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true +private fun showNonContentUriAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.non_content_uri_alert_title), + text = generalGetString(MR.strings.non_content_uri_alert_text) + ) +} + //fun testJson() { // val str: String = """ // """.trimIndent() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 1ae85cd050..b5ffd6630f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -16,6 +16,8 @@ Opening database… + Invalid file path + You shared an invalid file path. Report the issue to the app developers. connected From 1dcd2760b0b803a9e050532c8505379db6516288 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 06:01:47 +0800 Subject: [PATCH 63/80] ui: show alert after saving profile with existing name (#3273) * android, desktop: show alert after saving profile with existing name * ios: show alert after saving profile with existing name * return statements * better way of showing alert --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 3 ++- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 4 ++-- apps/ios/Shared/Views/ChatList/ChatListNavLink.swift | 4 ++-- apps/ios/Shared/Views/UserSettings/UserProfile.swift | 4 +++- apps/ios/SimpleXChat/AppGroup.swift | 6 +++--- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 3 +++ .../simplex/common/views/usersettings/UserProfileView.kt | 2 +- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 99e8c02847..680a7132df 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -720,8 +720,9 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { let userId = try currentUserId("apiUpdateProfile") let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { - case .userProfileNoChange: return nil + case .userProfileNoChange: return (profile, []) case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts) + case .chatCmdError(_, .errorStore(.duplicateName)): return nil; default: throw r } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec4cc0fc4c..b90c9e7479 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -242,7 +242,7 @@ struct ChatInfoView: View { } .actionSheet(isPresented: $showDeleteContactActionSheet) { if contact.ready && contact.active { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete and notify contact")) { deleteContact(notify: true) }, @@ -251,7 +251,7 @@ struct ChatInfoView: View { ] ) } else { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete")) { deleteContact() }, diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index be912d666a..088335d198 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -78,7 +78,7 @@ struct ChatListNavLink: View { .frame(height: rowHeights[dynamicTypeSize]) .actionSheet(isPresented: $showDeleteContactActionSheet) { if contact.ready && contact.active { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } }, @@ -87,7 +87,7 @@ struct ChatListNavLink: View { ] ) } else { - ActionSheet( + return ActionSheet( title: Text("Delete contact?\nThis cannot be undone!"), buttons: [ .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index b1a362a5a1..b64ec21de6 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -174,11 +174,13 @@ struct UserProfile: View { chatModel.updateCurrentUser(newProfile) profile = newProfile } + editProfile = false + } else { + alert = .duplicateUserError } } catch { logger.error("UserProfile apiUpdateProfile error: \(responseError(error))") } - editProfile = false } } } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 4943dbd4ea..cc61fae53f 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -83,9 +83,9 @@ public enum AppState: String { public var canSuspend: Bool { switch self { - case .active: true - case .bgRefresh: true - default: false + case .active: return true + case .bgRefresh: return true + default: return false } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 58850ce056..1abc823c08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -943,6 +943,9 @@ object ChatController { val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile to emptyList() if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts + if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) + } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") return null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index 38c9e58ad2..ea4ef79d42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -42,8 +42,8 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val (newProfile, _) = updated chatModel.updateCurrentUser(newProfile) profile = newProfile + close() } - close() } } ) From b0f55d6de5e745bfae534765c05cb5d88c3568b3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 25 Oct 2023 10:45:36 +0400 Subject: [PATCH 64/80] core: update simplexmq (check snd queue) (#3280) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index eb557e52f5..5c19fcd446 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 55a6157880396be899c010f880a42322cf65258a + tag: d920a2504b6d4653748da7d297cb13cd0a0f1f48 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6c41ff2a1a..26188aa774 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."55a6157880396be899c010f880a42322cf65258a" = "1fhhyi2060pp72izrqki6gazb759hcv9wypxf39jkwpqpvrn81hv"; + "https://github.com/simplex-chat/simplexmq.git"."d920a2504b6d4653748da7d297cb13cd0a0f1f48" = "0r53wn01z044h6myvd458n3hiqsz64kpv59khgybzwdw5mmqnp34"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/stack.yaml b/stack.yaml index e6f6f49b76..f0fcbab1de 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 55a6157880396be899c010f880a42322cf65258a + commit: d920a2504b6d4653748da7d297cb13cd0a0f1f48 - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher From 743597e8488e2a9e0bf918af4a0c96fecb96a3d6 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:56:59 +0800 Subject: [PATCH 65/80] ios: making message text view working better (#3279) * ios: making message text view working better * style for ternaries --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../ComposeMessage/NativeTextEditor.swift | 74 ++++++++++++++++--- .../Chat/ComposeMessage/SendMessageView.swift | 39 +++------- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 51deced72c..3eead5b0af 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -14,20 +14,28 @@ import PhotosUI struct NativeTextEditor: UIViewRepresentable { @Binding var text: String @Binding var disableEditing: Bool - let height: CGFloat - let font: UIFont + @Binding var height: CGFloat @Binding var focused: Bool let alignment: TextAlignment let onImagesAdded: ([UploadContent]) -> Void + private let minHeight: CGFloat = 37 + + private let defaultHeight: CGFloat = { + let field = CustomUITextField(height: Binding.constant(0)) + field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) + }() + func makeUIView(context: Context) -> UITextView { - let field = CustomUITextField() + let field = CustomUITextField(height: _height) field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right field.autocapitalizationType = .sentences field.setOnTextChangedListener { newText, images in if !disableEditing { + // Speed up the process of updating layout, reduce jumping content on screen + if !isShortEmoji(newText) { updateHeight(field) } text = newText } else { field.text = text @@ -39,24 +47,72 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + updateFont(field) + updateHeight(field) return field } func updateUIView(_ field: UITextView, context: Context) { field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right + updateFont(field) + updateHeight(field) + } + + private func updateHeight(_ field: UITextView) { + let maxHeight = min(360, field.font!.lineHeight * 12) + // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size + let newHeight = field.text == "" + ? defaultHeight + : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down) + + if field.frame.size.height != newHeight { + field.frame.size = CGSizeMake(field.frame.size.width, newHeight) + (field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight) + } + } + + private func updateFont(_ field: UITextView) { + field.font = isShortEmoji(field.text) + ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) + : UIFont.preferredFont(forTextStyle: .body) } } private class CustomUITextField: UITextView, UITextViewDelegate { + var height: Binding + var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + init(height: Binding) { + self.height = height + super.init(frame: .zero, textContainer: nil) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + // This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft), + // produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call + // (who knows why...) + func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) { + self.newHeight = newHeight + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + if height.wrappedValue != newHeight { + DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight }) + } + return CGSizeMake(0, newHeight) + } + func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } - + func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged } @@ -144,14 +200,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate { struct NativeTextEditor_Previews: PreviewProvider{ static var previews: some View { - return NativeTextEditor( + NativeTextEditor( text: Binding.constant("Hello, world!"), disableEditing: Binding.constant(false), - height: 100, - font: UIFont.preferredFont(forTextStyle: .body), + height: Binding.constant(100), focused: Binding.constant(false), alignment: TextAlignment.leading, onImagesAdded: { _ in } ) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 6eed51788e..8f7b23c888 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,15 +32,12 @@ struct SendMessageView: View { var sendButtonColor = Color.accentColor @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body - @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 @State private var showCustomDisappearingMessageDialogue = false @State private var showCustomTimePicker = false @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var progressByTimeout = false - var maxHeight: CGFloat = 360 - var minHeight: CGFloat = 37 @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { @@ -57,30 +54,16 @@ struct SendMessageView: View { .frame(maxWidth: .infinity) } else { let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading - Text(composeState.message) - .lineLimit(10) - .font(teFont) - .multilineTextAlignment(alignment) -// put text on top (after NativeTextEditor) and set color to precisely align it on changes -// .foregroundColor(.red) - .foregroundColor(.clear) - .padding(.horizontal, 10) - .padding(.top, 8) - .padding(.bottom, 6) - .matchedGeometryEffect(id: "te", in: namespace) - .background(GeometryReader(content: updateHeight)) - NativeTextEditor( text: $composeState.message, disableEditing: $composeState.inProgress, - height: teHeight, - font: teUiFont, + height: $teHeight, focused: $keyboardVisible, alignment: alignment, onImagesAdded: onMediaAdded ) .allowsTightening(false) - .frame(height: teHeight) + .fixedSize(horizontal: false, vertical: true) } } @@ -100,11 +83,13 @@ struct SendMessageView: View { .frame(height: teHeight, alignment: .bottom) } } - + .padding(.vertical, 1) + .overlay( RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - .frame(height: teHeight) + ) } + .onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.inProgress) { inProgress in if inProgress { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { @@ -415,16 +400,12 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } - private func updateHeight(_ g: GeometryProxy) -> Color { + private func updateFont(_ text: String) { DispatchQueue.main.async { - teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) - (teFont, teUiFont) = isShortEmoji(composeState.message) - ? composeState.message.count < 4 - ? (largeEmojiFont, largeEmojiUIFont) - : (mediumEmojiFont, mediumEmojiUIFont) - : (.body, UIFont.preferredFont(forTextStyle: .body)) + teFont = isShortEmoji(text) + ? (text.count < 4 ? largeEmojiFont : mediumEmojiFont) + : .body } - return Color.clear } } From 4a8da196ad0851a5183bee5a4239e2e8bccf23e7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 25 Oct 2023 11:55:06 +0100 Subject: [PATCH 66/80] core: more permissive display name validation, only allow single quotes in CLI for the names with spaces (#3282) --- src/Simplex/Chat.hs | 18 ++++++++++++------ tests/ValidNames.hs | 22 +++++++++++++++++----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c70c842c04..4b6359f135 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5839,7 +5839,7 @@ chatCommandP = mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal - displayName = safeDecodeUtf8 <$> (quoted "'\"" <|> takeNameTill isSpace) + displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) where takeNameTill p = A.peekChar' >>= \c -> @@ -5954,14 +5954,20 @@ timeItToView s action = do pure a mkValidName :: String -> String -mkValidName = reverse . dropWhile isSpace . fst . foldl' addChar ("", '\NUL') +mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where - addChar (r, prev) c = if notProhibited && validChar then (c' : r, c') else (r, prev) + fst3 (x, _, _) = x + addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) where c' = if isSpace c then ' ' else c + punct' + | isPunctuation c = punct + 1 + | isSpace c = punct + | otherwise = 0 validChar - | prev == '\NUL' || isSpace prev = validFirstChar - | isPunctuation prev = validFirstChar || isSpace c + | c == '\'' = False + | prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar + | isSpace prev = validFirstChar || (punct == 0 && isPunctuation c) + | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c validFirstChar = isLetter c || isNumber c || isSymbol c - notProhibited = c `notElem` ("@#'\"`" :: String) diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index 40cda01431..0700d80846 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -14,14 +14,26 @@ testMkValidName = do mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "J.Doe" `shouldBe` "J.Doe" mkValidName "J. Doe" `shouldBe` "J. Doe" - mkValidName "J..Doe" `shouldBe` "J.Doe" - mkValidName "J ..Doe" `shouldBe` "J Doe" - mkValidName "J . . Doe" `shouldBe` "J Doe" + mkValidName "J..Doe" `shouldBe` "J..Doe" + mkValidName "J ..Doe" `shouldBe` "J ..Doe" + mkValidName "J ... Doe" `shouldBe` "J ... Doe" + mkValidName "J .... Doe" `shouldBe` "J ... Doe" + mkValidName "J . . Doe" `shouldBe` "J . Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" mkValidName " alice" `shouldBe` "alice" mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "'John Doe'" `shouldBe` "John Doe" - mkValidName "\"John Doe\"" `shouldBe` "John Doe" - mkValidName "`John Doe`" `shouldBe` "John Doe" + mkValidName "\"John Doe\"" `shouldBe` "John Doe\"" + mkValidName "`John Doe`" `shouldBe` "`John Doe`" + mkValidName "John \"Doe\"" `shouldBe` "John \"Doe\"" + mkValidName "John `Doe`" `shouldBe` "John `Doe`" + mkValidName "alice/bob" `shouldBe` "alice/bob" + mkValidName "alice / bob" `shouldBe` "alice / bob" + mkValidName "alice /// bob" `shouldBe` "alice /// bob" + mkValidName "alice //// bob" `shouldBe` "alice /// bob" + mkValidName "alice >>= bob" `shouldBe` "alice >>= bob" + mkValidName "alice@example.com" `shouldBe` "alice@example.com" + mkValidName "alice <> bob" `shouldBe` "alice <> bob" + mkValidName "alice -> bob" `shouldBe` "alice -> bob" From 4a5fdd3e0eed0d3eae2670bc7829d9ced073d892 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 26 Oct 2023 10:32:11 +0400 Subject: [PATCH 67/80] ios, android: show progress indicator on joining group (#3281) --- .../Chat/ChatItem/CIGroupInvitationView.swift | 68 +++++++++++------ .../Views/ChatList/ChatListNavLink.swift | 58 ++++++++++----- .../Views/ChatList/ChatPreviewView.swift | 19 +++-- .../simplex/common/views/chat/ChatView.kt | 21 +++--- .../views/chat/item/CIGroupInvitationView.kt | 74 +++++++++++++------ .../common/views/chat/item/ChatItemView.kt | 6 +- .../views/chatlist/ChatListNavLinkView.kt | 71 ++++++++++++++---- .../common/views/chatlist/ChatPreviewView.kt | 38 +++++++--- 8 files changed, 252 insertions(+), 103 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index c7ec3ca713..72013877ca 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -17,34 +17,45 @@ struct CIGroupInvitationView: View { var memberRole: GroupMemberRole var chatIncognito: Bool = false @State private var frameWidth: CGFloat = 0 + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let v = ZStack(alignment: .bottomTrailing) { - VStack(alignment: .leading) { - groupInfoView(action) - .padding(.horizontal, 2) - .padding(.top, 8) - .padding(.bottom, 6) - .overlay(DetermineWidth()) + ZStack { + VStack(alignment: .leading) { + groupInfoView(action) + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(DetermineWidth()) - Divider().frame(width: frameWidth) + Divider().frame(width: frameWidth) - if action { - groupInvitationText() - .overlay(DetermineWidth()) - Text(chatIncognito ? "Tap to join incognito" : "Tap to join") - .foregroundColor(chatIncognito ? .indigo : .accentColor) - .font(.callout) - .padding(.trailing, 60) - .overlay(DetermineWidth()) - } else { - groupInvitationText() - .padding(.trailing, 60) - .overlay(DetermineWidth()) + if action { + VStack(alignment: .leading, spacing: 2) { + groupInvitationText() + .overlay(DetermineWidth()) + Text(chatIncognito ? "Tap to join incognito" : "Tap to join") + .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor) + .font(.callout) + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } else { + groupInvitationText() + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } + .padding(.bottom, 2) + + if progressByTimeout { + ProgressView().scaleEffect(2) } } - .padding(.bottom, 2) + chatItem.timestampText .font(.caption) .foregroundColor(.secondary) @@ -55,11 +66,24 @@ struct CIGroupInvitationView: View { .cornerRadius(18) .textSelection(.disabled) .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } + } if action { v.onTapGesture { - joinGroup(groupInvitation.groupId) + inProgress = true + joinGroup(groupInvitation.groupId) { + await MainActor.run { inProgress = false } + } } + .disabled(inProgress) } else { v } @@ -67,7 +91,7 @@ struct CIGroupInvitationView: View { private func groupInfoView(_ action: Bool) -> some View { var color: Color - if action { + if action && !inProgress { color = chatIncognito ? .indigo : .accentColor } else { color = Color(uiColor: .tertiaryLabel) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 088335d198..971c0e0888 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -33,19 +33,32 @@ struct ChatListNavLink: View { @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false @State private var showDeleteContactActionSheet = false + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { - switch chat.chatInfo { - case let .direct(contact): - contactNavLink(contact) - case let .group(groupInfo): - groupNavLink(groupInfo) - case let .contactRequest(cReq): - contactRequestNavLink(cReq) - case let .contactConnection(cConn): - contactConnectionNavLink(cConn) - case let .invalidJSON(json): - invalidJSONPreview(json) + Group { + switch chat.chatInfo { + case let .direct(contact): + contactNavLink(contact) + case let .group(groupInfo): + groupNavLink(groupInfo) + case let .contactRequest(cReq): + contactRequestNavLink(cReq) + case let .contactConnection(cConn): + contactConnectionNavLink(cConn) + case let .invalidJSON(json): + invalidJSONPreview(json) + } + } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } } } @@ -53,7 +66,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) } + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } ) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() @@ -101,7 +114,7 @@ struct ChatListNavLink: View { @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) .frame(height: rowHeights[dynamicTypeSize]) .swipeActions(edge: .trailing, allowsFullSwipe: true) { joinGroupButton() @@ -112,12 +125,16 @@ struct ChatListNavLink: View { .onTapGesture { showJoinGroupDialog = true } .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { - joinGroup(groupInfo.groupId) + inProgress = true + joinGroup(groupInfo.groupId) { + await MainActor.run { inProgress = false } + } } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } } + .disabled(inProgress) case .memAccepted: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) @@ -134,7 +151,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) }, + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !groupInfo.ready ) .frame(height: rowHeights[dynamicTypeSize]) @@ -159,7 +176,10 @@ struct ChatListNavLink: View { private func joinGroupButton() -> some View { Button { - joinGroup(chat.chatInfo.apiId) + inProgress = true + joinGroup(chat.chatInfo.apiId) { + await MainActor.run { inProgress = false } + } } label: { Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") } @@ -419,7 +439,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, ) } -func joinGroup(_ groupId: Int64) { +func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { Task { logger.debug("joinGroup") do { @@ -434,7 +454,9 @@ func joinGroup(_ groupId: Int64) { AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") await deleteGroup() } + await onComplete() } catch let error { + await onComplete() let a = getErrorAlert(error, "Error joining group") AlertManager.shared.showAlertMsg(title: a.title, message: a.message) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index b7b7e73dca..71f8baf748 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat + @Binding var progressByTimeout: Bool @Environment(\.colorScheme) var colorScheme var darkGreen = Color(red: 0, green: 0.5, blue: 0) @@ -252,6 +253,12 @@ struct ChatPreviewView: View { } else { incognitoIcon(chat.chatInfo.incognito) } + case .group: + if progressByTimeout { + ProgressView() + } else { + incognitoIcon(chat.chatInfo.incognito) + } default: incognitoIcon(chat.chatInfo.incognito) } @@ -280,30 +287,30 @@ struct ChatPreviewView_Previews: PreviewProvider { ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 78)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 40f2b32e58..94f9a6b54e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -269,8 +269,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: cancelFile = { fileId -> withApi { chatModel.controller.cancelFile(user, fileId) } }, - joinGroup = { groupId -> - withApi { chatModel.controller.apiJoinGroup(groupId) } + joinGroup = { groupId, onComplete -> + withApi { + chatModel.controller.apiJoinGroup(groupId) + onComplete.invoke() + } }, startCall = out@ { media -> withBGApi { @@ -431,7 +434,7 @@ fun ChatLayout( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, startCall: (CallMediaType) -> Unit, endCall: () -> Unit, acceptCall: (Contact) -> Unit, @@ -720,7 +723,7 @@ fun BoxWithConstraintsScope.ChatItemsList( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -872,7 +875,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { MemberImage(member) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } else { @@ -881,7 +884,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } @@ -891,7 +894,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) } } } else { // direct message @@ -1323,7 +1326,7 @@ fun PreviewChatLayout() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, endCall = {}, acceptCall = { _ -> }, @@ -1393,7 +1396,7 @@ fun PreviewGroupChatLayout() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, endCall = {}, acceptCall = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 6ee29b8304..56dd7a360a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource @@ -17,6 +18,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.res.MR +import kotlinx.coroutines.delay @Composable fun CIGroupInvitationView( @@ -24,16 +26,26 @@ fun CIGroupInvitationView( groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole, chatIncognito: Boolean = false, - joinGroup: (Long) -> Unit + joinGroup: (Long, () -> Unit) -> Unit ) { val sent = ci.chatDir.sent val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } @Composable fun groupInfoView() { val p = groupInvitation.groupProfile val iconColor = - if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary + if (action && !inProgress.value) if (chatIncognito) Indigo else MaterialTheme.colors.primary else if (isInDarkTheme()) FileDark else FileLight Row( @@ -70,8 +82,9 @@ fun CIGroupInvitationView( val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( - modifier = if (action) Modifier.clickable(onClick = { - joinGroup(groupInvitation.groupId) + modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { + inProgress.value = true + joinGroup(groupInvitation.groupId) { inProgress.value = false } }) else Modifier, shape = RoundedCornerShape(18.dp), color = if (sent) sentColor else receivedColor, @@ -83,26 +96,45 @@ fun CIGroupInvitationView( .padding(start = 8.dp, end = 12.dp), contentAlignment = Alignment.BottomEnd ) { - Column( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(bottom = 4.dp), + Box( + contentAlignment = Alignment.Center ) { - groupInfoView() - Column(Modifier.padding(top = 2.dp, start = 5.dp)) { - Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) - if (action) { - groupInvitationText() - Text(stringResource( - if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join), - color = if (chatIncognito) Indigo else MaterialTheme.colors.primary) - } else { - Box(Modifier.padding(end = 48.dp)) { + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 4.dp), + ) { + groupInfoView() + Column(Modifier.padding(top = 2.dp, start = 5.dp)) { + Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) + if (action) { groupInvitationText() + Text( + stringResource( + if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join + ), + color = if (inProgress.value) + MaterialTheme.colors.secondary + else + if (chatIncognito) Indigo else MaterialTheme.colors.primary + ) + } else { + Box(Modifier.padding(end = 48.dp)) { + groupInvitationText() + } } } } + + if (progressByTimeout) { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 3.dp + ) + } } + Text( ci.timestampText, color = MaterialTheme.colors.secondary, @@ -124,7 +156,7 @@ fun PendingCIGroupInvitationViewPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -140,7 +172,7 @@ fun CIGroupInvitationViewAcceptedPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -156,7 +188,7 @@ fun CIGroupInvitationViewLongNamePreview() { status = CIGroupInvitationStatus.Accepted ), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index dd07a3fc19..dd9fe4d4a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -50,7 +50,7 @@ fun ChatItemView( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, @@ -578,7 +578,7 @@ fun PreviewChatItemView() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, @@ -609,7 +609,7 @@ fun PreviewChatItemViewDeletedContent() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 2ff33ead57..bcabb7cfd4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -44,11 +45,22 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } + when (chat.chatInfo) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -58,9 +70,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, - click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, - dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, + click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) }, + dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, showMenu, stopped, selectedChat @@ -110,9 +122,9 @@ fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) { withBGApi { openChat(chatInfo, chatModel) } } -fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { +fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) } } @@ -193,10 +205,19 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { +fun GroupMenuItems( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState, + showMarkRead: Boolean +) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - JoinGroupAction(chat, groupInfo, chatModel, showMenu) + if (!inProgress.value) { + JoinGroupAction(chat, groupInfo, chatModel, showMenu, inProgress) + } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) } @@ -317,8 +338,20 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh } @Composable -fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { - val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } } +fun JoinGroupAction( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState +) { + val joinGroup: () -> Unit = { + withApi { + inProgress.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress.value = false + } + } ItemAction( if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button), if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login), @@ -558,12 +591,18 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { ) } -fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) { +fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), confirmText = if (groupInfo.membership.memberIncognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }, + onConfirm = { + withApi { + inProgress?.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress?.value = false + } + }, dismissText = generalGetString(MR.strings.delete_verb), onDismiss = { deleteGroup(groupInfo, chatModel) } ) @@ -680,7 +719,9 @@ fun PreviewChatListNavLinkDirect() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, @@ -721,7 +762,9 @@ fun PreviewChatListNavLinkGroup() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index a5775d369b..d3413e2e08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -37,7 +37,9 @@ fun ChatPreviewView( currentUserProfileDisplayName: String?, contactNetworkStatus: NetworkStatus?, stopped: Boolean, - linkMode: SimplexLinkMode + linkMode: SimplexLinkMode, + inProgress: Boolean, + progressByTimeout: Boolean ) { val cInfo = chat.chatInfo @@ -135,7 +137,12 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary) + GroupMemberStatus.MemInvited -> chatPreviewTitleText( + if (inProgress) + MaterialTheme.colors.secondary + else + if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary + ) GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary) else -> chatPreviewTitleText() } @@ -194,6 +201,17 @@ fun ChatPreviewView( } } + @Composable + fun progressView() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { @@ -213,17 +231,17 @@ fun ChatPreviewView( ) else -> - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) + progressView() } } else { IncognitoIcon(chat.chatInfo.incognito) } + } else if (cInfo is ChatInfo.Group) { + if (progressByTimeout) { + progressView() + } else { + IncognitoIcon(chat.chatInfo.incognito) + } } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -351,6 +369,6 @@ fun unreadCountStr(n: Int): String { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false) } } From 7102723c23ed73c668f717f60e45cbf2354098d8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 26 Oct 2023 18:51:45 +0400 Subject: [PATCH 68/80] ios: create new group with incognito membership (#3284) * ios: create new group with incognito membership * layout * fix button * update layout * layout * layout --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- apps/ios/Shared/Views/Chat/ChatView.swift | 38 +++- .../Views/Chat/Group/GroupChatInfoView.swift | 12 +- .../Views/Chat/Group/GroupLinkView.swift | 40 +++- .../Shared/Views/NewChat/AddGroupView.swift | 171 +++++++++++------- .../Views/NewChat/PasteToConnectView.swift | 8 +- .../Views/NewChat/ScanToConnectView.swift | 6 +- apps/ios/SimpleXChat/APITypes.swift | 4 +- 8 files changed, 191 insertions(+), 92 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 680a7132df..de09853e12 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1017,9 +1017,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { +func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") - let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) + let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 21af0ebe17..5679b451a0 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -37,6 +37,10 @@ struct ChatView: View { @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @State private var selectedMember: GroupMember? = nil + // opening GroupLinkView on link button (incognito) + @State private var showGroupLinkSheet: Bool = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if #available(iOS 16.0, *) { @@ -173,9 +177,16 @@ struct ChatView: View { HStack { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { - Image(systemName: "person.crop.circle.badge.plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) } + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } } else { addMembersButton() .appSheet(isPresented: $showAddMembersSheet) { @@ -417,7 +428,26 @@ struct ChatView: View { Image(systemName: "person.crop.circle.badge.plus") } } - + + private func groupLinkButton() -> some View { + Button { + if case let .group(gInfo) = chat.chatInfo { + Task { + do { + if let link = try apiGetGroupLink(gInfo.groupId) { + (groupLink, groupLinkMemberRole) = link + } + } catch let error { + logger.error("ChatView apiGetGroupLink: \(responseError(error))") + } + showGroupLinkSheet = true + } + } + } label: { + Image(systemName: "link.badge.plus") + } + } + private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { if loadingItems || firstPage { return } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 3b9ef347e8..dd2392b6dc 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -225,9 +225,15 @@ struct GroupChatInfoView: View { private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) - .navigationBarTitle("Group link") - .navigationBarTitleDisplayMode(.large) + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: false, + creatingGroup: false + ) + .navigationBarTitle("Group link") + .navigationBarTitleDisplayMode(.large) } label: { if groupLink == nil { Label("Create group link", systemImage: "link.badge.plus") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 781870bf5e..bf2179bea4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -13,6 +13,9 @@ struct GroupLinkView: View { var groupId: Int64 @Binding var groupLink: String? @Binding var groupLinkMemberRole: GroupMemberRole + var showTitle: Bool = false + var creatingGroup: Bool = false + var linkCreatedCb: (() -> Void)? = nil @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -29,10 +32,35 @@ struct GroupLinkView: View { } var body: some View { + if creatingGroup { + NavigationView { + groupLinkView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } + } + } + } + } else { + groupLinkView() + } + } + + private func groupLinkView() -> some View { List { - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Group { + if showTitle { + Text("Group link") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + } + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Section { if let groupLink = groupLink { Picker("Initial role", selection: $groupLinkMemberRole) { @@ -48,8 +76,10 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } - Button(role: .destructive) { alert = .deleteLink } label: { - Label("Delete link", systemImage: "trash") + if !creatingGroup { + Button(role: .destructive) { alert = .deleteLink } label: { + Label("Delete link", systemImage: "trash") + } } } else { Button(action: createGroupLink) { diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 186a24e995..22bf1c4096 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct AddGroupView: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @State private var chat: Chat? @State private var groupInfo: GroupInfo? @State private var profile = GroupProfile(displayName: "", fullName: "") @@ -21,18 +22,35 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if let chat = chat, let groupInfo = groupInfo { - AddGroupMembersViewCommon( - chat: chat, - groupInfo: groupInfo, - creatingGroup: true, - showFooterCounter: false - ) { _ in - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - m.chatId = groupInfo.id + if !groupInfo.membership.memberIncognito { + AddGroupMembersViewCommon( + chat: chat, + groupInfo: groupInfo, + creatingGroup: true, + showFooterCounter: false + ) { _ in + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } + } + } else { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: true + ) { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } } } } else { @@ -41,77 +59,62 @@ struct AddGroupView: View { } func createGroupView() -> some View { - VStack(alignment: .leading) { - Text("Create secret group") - .font(.largeTitle) - .padding(.vertical, 4) - Text("The group is fully decentralized – it is visible only to the members.") - .padding(.bottom, 4) + List { + Group { + Text("Create secret group") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 24) + .onTapGesture(perform: hideKeyboard) - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to group members").font(.footnote) - } - .padding(.bottom) - - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - profileImageView(profile.image) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground)) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 128, maxHeight: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } + + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable } - - editImageButton { showChooseSource = true } + .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 4) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - ZStack(alignment: .topLeading) { - let name = profile.displayName.trimmingCharacters(in: .whitespaces) - if name != mkValidName(name) { - Button { - showInvalidNameAlert = true - } label: { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } - } else { - Image(systemName: "exclamationmark.circle").foregroundColor(.clear) + Section { + groupNameTextField() + Button(action: createGroup) { + settingsRow("checkmark", color: .accentColor) { Text("Create group") } } - textField("Enter group name…", text: $profile.displayName) - .focused($focusDisplayName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createGroup() } - } + .disabled(!canCreateProfile()) + IncognitoToggle(incognitoEnabled: $incognitoDefault) + } footer: { + VStack(alignment: .leading, spacing: 4) { + sharedGroupProfileInfo(incognitoDefault) + Text("Fully decentralized – visible only to members.") + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture(perform: hideKeyboard) } - .padding(.bottom) - - Spacer() - - Button { - createGroup() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) - .frame(maxWidth: .infinity, alignment: .trailing) } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { focusDisplayName = true } } - .padding() .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true @@ -141,20 +144,48 @@ struct AddGroupView: View { profile.image = nil } } - .contentShape(Rectangle()) - .onTapGesture { hideKeyboard() } + } + + func groupNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidNameAlert = true + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(.secondary) + } + textField("Enter group name…", text: $profile.displayName) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() { createGroup() } + } + } } func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { TextField(placeholder, text: text) - .padding(.leading, 32) + .padding(.leading, 36) + } + + func sharedGroupProfileInfo(_ incognito: Bool) -> Text { + let name = ChatModel.shared.currentUser?.displayName ?? "" + return Text( + incognito + ? "A new random profile will be shared." + : "Your profile **\(name)** will be shared." + ) } func createGroup() { hideKeyboard() do { profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) - let gInfo = try apiNewGroup(profile) + let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index af84f19fec..7c272fb631 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -54,9 +54,11 @@ struct PasteToConnectView: View { IncognitoToggle(incognitoEnabled: $incognitoDefault) } footer: { - sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + VStack(alignment: .leading, spacing: 4) { + sharedProfileInfo(incognitoDefault) + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + } + .frame(maxWidth: .infinity, alignment: .leading) } } .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index c55ba1502e..9a11eee92b 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -38,11 +38,11 @@ struct ScanToConnectView: View { ) .padding(.top) - Group { + VStack(alignment: .leading, spacing: 4) { sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") } + .frame(maxWidth: .infinity, alignment: .leading) .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5c7220f374..e270674784 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -50,7 +50,7 @@ public enum ChatCommand { case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) case apiGetNtfMessage(nonce: String, encNtfInfo: String) - case apiNewGroup(userId: Int64, groupProfile: GroupProfile) + case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) @@ -175,7 +175,7 @@ public enum ChatCommand { case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" - case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" + case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" From a7b5dfb74c087ba729e235952b165e20beed3e45 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 27 Oct 2023 09:33:59 +0400 Subject: [PATCH 69/80] android: create new group with incognito membership (#3285) --- .../chat/simplex/common/model/SimpleXAPI.kt | 8 +- .../simplex/common/views/chat/ChatView.kt | 41 +++++++-- .../common/views/chat/group/GroupLinkView.kt | 60 ++++++++++--- .../common/views/newchat/AddGroupView.kt | 90 ++++++++++--------- .../common/views/newchat/PasteToConnect.kt | 6 +- .../commonMain/resources/MR/base/strings.xml | 4 +- 6 files changed, 138 insertions(+), 71 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 1abc823c08..b751cb56c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1166,9 +1166,9 @@ object ChatController { } } - suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { + suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } - val r = sendCmd(CC.ApiNewGroup(userId, p)) + val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null @@ -1889,7 +1889,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() - class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() + class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() @@ -1999,7 +1999,7 @@ sealed class CC { is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" - is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" + is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 94f9a6b54e..ac7161044f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -389,6 +389,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, + openGroupLink = { groupInfo -> + hideKeyboard(view) + withApi { + val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { + GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + } + } + }, markRead = { range, unreadCountAfter -> chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) ntfManager.cancelNotificationsForChat(chat.id) @@ -449,6 +459,7 @@ fun ChatLayout( setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, @@ -495,7 +506,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -526,6 +537,7 @@ fun ChatInfoToolbar( startCall: (CallMediaType) -> Unit, endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, ) { @@ -607,13 +619,24 @@ fun ChatInfoToolbar( }) } } - } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) { - barButtons.add { - IconButton({ - showMenu.value = false - addMembers(chat.chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) { + if (!chat.chatInfo.incognito) { + barButtons.add { + IconButton({ + showMenu.value = false + addMembers(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } + } + } else { + barButtons.add { + IconButton({ + showMenu.value = false + openGroupLink(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary) + } } } } @@ -1341,6 +1364,7 @@ fun PreviewChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, @@ -1411,6 +1435,7 @@ fun PreviewGroupChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 7e1c03130a..809c7c2fd6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -23,7 +23,15 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable -fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit) { +fun GroupLinkView( + chatModel: ChatModel, + groupInfo: GroupInfo, + connReqContact: String?, + memberRole: GroupMemberRole?, + onGroupLinkUpdated: ((Pair?) -> Unit)?, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null +) { var groupLink by rememberSaveable { mutableStateOf(connReqContact) } val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } var creatingLink by rememberSaveable { mutableStateOf(false) } @@ -34,7 +42,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } creatingLink = false } @@ -58,7 +66,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } } } @@ -73,13 +81,15 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) if (r) { groupLink = null - onGroupLinkUpdated(null) + onGroupLinkUpdated?.invoke(null) } } }, destructive = true, ) - } + }, + creatingGroup = creatingGroup, + close = close ) if (creatingLink) { ProgressIndicator() @@ -94,8 +104,19 @@ fun GroupLinkLayout( creatingLink: Boolean, createLink: () -> Unit, updateLink: () -> Unit, - deleteLink: () -> Unit + deleteLink: () -> Unit, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null ) { + @Composable + fun ContinueButton(close: () -> Unit) { + SimpleButton( + stringResource(MR.strings.continue_to_next_step), + icon = painterResource(MR.images.ic_check), + click = close + ) + } + Column( Modifier .verticalScroll(rememberScrollState()), @@ -112,7 +133,16 @@ fun GroupLinkLayout( verticalArrangement = Arrangement.SpaceEvenly ) { if (groupLink == null) { - SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) + ) { + SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + if (creatingGroup && close != null) { + ContinueButton(close) + } + } } else { RoleSelectionRow(groupInfo, groupLinkMemberRole) var initialLaunch by remember { mutableStateOf(true) } @@ -134,12 +164,16 @@ fun GroupLinkLayout( icon = painterResource(MR.images.ic_share), click = { clipboard.shareText(simplexChatLink(groupLink)) } ) - SimpleButton( - stringResource(MR.strings.delete_link), - icon = painterResource(MR.images.ic_delete), - color = Color.Red, - click = deleteLink - ) + if (creatingGroup && close != null) { + ContinueButton(close) + } else { + SimpleButton( + stringResource(MR.strings.delete_link), + icon = painterResource(MR.images.ic_delete), + color = Color.Red, + click = deleteLink + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index be446f6087..9b2cedefaa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -11,10 +12,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -22,11 +22,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.ReadableText -import chat.simplex.common.views.usersettings.DeleteImageButton -import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.platform.* import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -35,9 +34,9 @@ import java.net.URI @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { AddGroupLayout( - createGroup = { groupProfile -> + createGroup = { incognito, groupProfile -> withApi { - val groupInfo = chatModel.controller.apiNewGroup(groupProfile) + val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile) if (groupInfo != null) { chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() @@ -45,24 +44,36 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { chatModel.chatId.value = groupInfo.id setGroupMembers(groupInfo, chatModel) close.invoke() - ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, true, chatModel, close) + if (!groupInfo.incognito) { + ModalManager.end.showModalCloseable(true) { close -> + AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close) + } + } else { + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + } } } } }, + incognitoPref = chatModel.controller.appPrefs.incognito, close ) } @Composable -fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { +fun AddGroupLayout( + createGroup: (Boolean, GroupProfile) -> Unit, + incognitoPref: SharedPreference, + close: () -> Unit +) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } + val incognito = remember { mutableStateOf(incognitoPref.get()) } ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { ModalBottomSheetLayout( @@ -87,7 +98,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { .padding(horizontal = DEFAULT_PADDING) ) { AppBarTitle(stringResource(MR.strings.create_secret_group_title)) - ReadableText(MR.strings.group_is_decentralized, TextAlign.Center) Box( Modifier .fillMaxWidth() @@ -118,20 +128,32 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) Spacer(Modifier.height(8.dp)) - val enabled = canCreateProfile(displayName.value) - if (enabled) { - CreateGroupButton(MaterialTheme.colors.primary, Modifier - .clickable { - createGroup(GroupProfile( - displayName = displayName.value.trim(), - fullName = "", - image = profileImage.value - )) - } - .padding(8.dp)) - } else { - CreateGroupButton(MaterialTheme.colors.secondary, Modifier.padding(8.dp)) - } + + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.create_group_button), + click = { + createGroup(incognito.value, GroupProfile( + displayName = displayName.value.trim(), + fullName = "", + image = profileImage.value + )) + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreateProfile(displayName.value) + ) + + IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + + SectionTextFooter( + buildAnnotatedString { + append(sharedProfileInfo(chatModel, incognito.value)) + append("\n") + append(annotatedStringResource(MR.strings.group_is_decentralized)) + } + ) + LaunchedEffect(Unit) { delay(300) focusRequester.requestFocus() @@ -142,21 +164,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } } -@Composable -fun CreateGroupButton(color: Color, modifier: Modifier) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = color, fontWeight = FontWeight.Bold) - Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = color) - } - } - } -} - fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim()) @Preview @@ -164,7 +171,8 @@ fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmp fun PreviewAddGroupLayout() { SimpleXTheme { AddGroupLayout( - createGroup = {}, + createGroup = { _, _ -> }, + incognitoPref = SharedPreference({ false }, {}), close = {} ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index f7a5a1e86b..b142b8e16a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -3,10 +3,10 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview -import chat.simplex.common.platform.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource @@ -14,7 +14,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp -import chat.simplex.common.platform.TAG import chat.simplex.common.model.ChatModel import chat.simplex.common.model.SharedPreference import chat.simplex.common.ui.theme.* @@ -23,7 +22,6 @@ import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import java.net.URI -import java.net.URISyntaxException @Composable fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @@ -97,6 +95,8 @@ fun PasteToConnectLayout( painterResource(MR.images.ic_link), stringResource(MR.strings.connect_button), click = { connectViaLink(connectionLink.value) }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ") ) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b5ffd6630f..aa76a768e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1294,11 +1294,11 @@ Create secret group - The group is fully decentralized – it is visible only to the members. + Fully decentralized – visible only to members. Enter group name: Group full name: Your chat profile will be sent to group members - + Create group Group profile is stored on members\' devices, not on the servers. From 9568279b0f286a14666f82404345775030af02ad Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 29 Oct 2023 18:21:51 +0000 Subject: [PATCH 70/80] update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Messages/CIContent.hs | 8 +++++--- stack.yaml | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index 5c19fcd446..9522fe2337 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + tag: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26188aa774..ebce4b5c03 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d920a2504b6d4653748da7d297cb13cd0a0f1f48" = "0r53wn01z044h6myvd458n3hiqsz64kpv59khgybzwdw5mmqnp34"; + "https://github.com/simplex-chat/simplexmq.git"."0410948b56ea630dfa86441bbcf8ec97aeb1df01" = "1y4a28dkccbv8cbh164iirsnxa62qwac0pd5c8lqr5kddqvkz970"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index d3cdbcf3e4..639093d01b 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -9,12 +9,14 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -314,11 +316,11 @@ instance ToJSON DBRcvDirectEvent where newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where - parseJSON v = DBME <$> J.genericParseJSON (singleFieldJSON fstToLower) v + parseJSON v = DBME <$> $(JQ.mkParseJSON (singleFieldJSON fstToLower) ''MsgErrorType) v instance ToJSON DBMsgErrorType where - toJSON (DBME v) = J.genericToJSON (singleFieldJSON fstToLower) v - toEncoding (DBME v) = J.genericToEncoding (singleFieldJSON fstToLower) v + toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v + toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v data CIGroupInvitation = CIGroupInvitation { groupId :: GroupId, diff --git a/stack.yaml b/stack.yaml index f0fcbab1de..7ab6474893 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: d920a2504b6d4653748da7d297cb13cd0a0f1f48 + commit: 0410948b56ea630dfa86441bbcf8ec97aeb1df01 - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher From f34bbdbd9c13b4f4dbc7bf54515b1019b7655f17 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 30 Oct 2023 20:40:20 +0400 Subject: [PATCH 71/80] core: improve group link protocol (immediately establish group connection without first creating contact) (#3288) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 74 +++- src/Simplex/Chat/Controller.hs | 3 + .../M20231030_xgrplinkmem_received.hs | 18 + src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Protocol.hs | 20 +- src/Simplex/Chat/Store/Groups.hs | 138 ++++++- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Types.hs | 16 + src/Simplex/Chat/View.hs | 3 + tests/Bots/DirectoryTests.hs | 8 +- tests/ChatClient.hs | 10 + tests/ChatTests/Groups.hs | 338 +++++++++++++++++- tests/ChatTests/Utils.hs | 10 +- tests/ProtocolTests.hs | 8 +- 15 files changed, 613 insertions(+), 39 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index cdb659a66e..96c56fdbb7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -118,6 +118,7 @@ library Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash Simplex.Chat.Migrations.M20231010_member_settings Simplex.Chat.Migrations.M20231019_indexes + Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4b6359f135..52bf4a1852 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2599,6 +2599,24 @@ acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvI setCommandConnId db user cmdId connId pure ct +acceptGroupJoinRequestAsync :: ChatMonad m => User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> m GroupMember +acceptGroupJoinRequestAsync + user + gInfo@GroupInfo {groupProfile, membership} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId} + gLinkMemRole + incognitoProfile = do + gVar <- asks idsDrg + (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole + let Profile {displayName} = profileToSendOnAccept user incognitoProfile + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + msg = XGrpLinkInv $ GroupLinkInvitation (MemberIdRole userMemberId userRole) displayName (MemberIdRole memberId gLinkMemRole) groupProfile + subMode <- chatReadVar subscriptionMode + connIds <- agentAcceptContactAsync user True invId msg subMode + withStore $ \db -> do + liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode + getGroupMemberById db user groupMemberId + profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing where @@ -3402,8 +3420,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO update member profile pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" + XInfo _ -> pure () -- sent when connecting via group link XOk -> pure () - _ -> messageError "INFO from member must have x.grp.mem.info" + _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () CON -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -3424,11 +3443,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} + let Connection {viaUserContactLink} = conn + when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> processIntro intro `catchChatError` (toView . CRChatError (Just user)) where + sendXGrpLinkMem = do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + profileToSend = profileToSendOnAccept user profileMode + void $ sendDirectMessage conn (XGrpLinkMem profileToSend) (GroupId groupId) processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMessage conn (XGrpMemIntro $ memberInfo (reMember intro)) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent @@ -3461,6 +3486,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg msgMeta XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName msgMeta + -- XInfo p -> xInfoMember gInfo m' p -- TODO use for member profile update + XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg msgMeta XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv @@ -3721,7 +3748,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept}, groupId_, _) -> + Just (UserContactLink {autoAccept}, groupId_, gLinkMemRole) -> case autoAccept of Just AutoAccept {acceptIncognito} -> case groupId_ of Nothing -> do @@ -3732,8 +3759,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db user groupId let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - ct <- acceptContactRequestAsync user cReq profileMode - toView $ CRAcceptingGroupJoinRequest user gInfo ct + if isCompatibleRange chatVRange groupLinkNoContactVRange + then do + mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + else do + ct <- acceptContactRequestAsync user cReq profileMode + toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> toView $ CRReceivedContactRequest user cReq _ -> pure () @@ -4446,6 +4479,33 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | otherwise -> Nothing in setPreference_ SCFTimedMessages ctUserTMPref' ctUserPrefs + -- TODO use for member profile update + -- xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () + -- xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' + + xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> m () + xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do + xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId + if viaGroupLink && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived + then do + m' <- processMemberProfileUpdate gInfo m p' + withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True + let connectedIncognito = memberIncognito membership + probeMatchingMemberContact m' connectedIncognito + else messageError "x.grp.link.mem error: invalid group link host profile update" + + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> m GroupMember + processMemberProfileUpdate gInfo m@GroupMember {memberContactId} p' = + case memberContactId of + Nothing -> do + m' <- withStore $ \db -> updateMemberProfile db user m p' + toView $ CRGroupMemberUpdated user gInfo m m' + pure m' + Just mContactId -> do + mCt <- withStore $ \db -> getContact db user mContactId + Contact {profile} <- processContactProfileUpdate mCt p' True + pure m {memberProfile = profile} + createFeatureEnabledItems :: Contact -> m () createFeatureEnabledItems ct@Contact {mergedPreferences} = forM_ allChatFeatures $ \(ACF f) -> do @@ -4707,6 +4767,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ct <- withStore $ \db -> createDirectContact db user conn' p toView $ CRContactConnecting user ct pure conn' + XGrpLinkInv glInv -> do + (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db user conn' glInv + toView $ CRGroupLinkConnecting user gInfo host + pure conn' -- TODO show/log error, other events in SMP confirmation _ -> pure conn' @@ -5488,7 +5552,7 @@ getCreateActiveUser st testView = do where loop = do displayName <- getContactName - withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} True) >>= \case + withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) (profileFromName displayName) True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 74501ad1e5..5f386dea0d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -474,6 +474,7 @@ data ChatResponse | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} + | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} @@ -489,6 +490,7 @@ data ChatResponse | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} | CRContactDeletedByContact {user :: User, contact :: Contact} @@ -559,6 +561,7 @@ data ChatResponse | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} + | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs b/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs new file mode 100644 index 0000000000..cf4aee2531 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231030_xgrplinkmem_received where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231030_xgrplinkmem_received :: Query +m20231030_xgrplinkmem_received = + [sql| +ALTER TABLE group_members ADD COLUMN xgrplinkmem_received INTEGER NOT NULL DEFAULT 0; +|] + +down_m20231030_xgrplinkmem_received :: Query +down_m20231030_xgrplinkmem_received = + [sql| +ALTER TABLE group_members DROP COLUMN xgrplinkmem_received; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index cadb7caf42..8e277a9789 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -146,6 +146,7 @@ CREATE TABLE group_members( updated_at TEXT CHECK(updated_at NOT NULL), member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, show_messages INTEGER NOT NULL DEFAULT 1, + xgrplinkmem_received INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 0f69efe7c0..58aa26f284 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -51,7 +51,7 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) currentChatVersion :: Version -currentChatVersion = 2 +currentChatVersion = 3 supportedChatVRange :: VersionRange supportedChatVRange = mkVersionRange 1 currentChatVersion @@ -64,6 +64,10 @@ groupNoDirectVRange = mkVersionRange 2 currentChatVersion xGrpDirectInvVRange :: VersionRange xGrpDirectInvVRange = mkVersionRange 2 currentChatVersion +-- version range that supports joining group via group link without creating direct contact +groupLinkNoContactVRange :: VersionRange +groupLinkNoContactVRange = mkVersionRange 3 currentChatVersion + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -218,6 +222,8 @@ data ChatMsgEvent (e :: MsgEncoding) where XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json + XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json + XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -559,6 +565,8 @@ data CMEventTag (e :: MsgEncoding) where XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json + XGrpLinkInv_ :: CMEventTag 'Json + XGrpLinkMem_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -606,6 +614,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" + XGrpLinkInv_ -> "x.grp.link.inv" + XGrpLinkMem_ -> "x.grp.link.mem" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -654,6 +664,8 @@ instance StrEncoding ACMEventTag where "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ + "x.grp.link.inv" -> XGrpLinkInv_ + "x.grp.link.mem" -> XGrpLinkMem_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -698,6 +710,8 @@ toCMEventTag msg = case msg of XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ + XGrpLinkInv _ -> XGrpLinkInv_ + XGrpLinkMem _ -> XGrpLinkMem_ XGrpMemNew _ -> XGrpMemNew_ XGrpMemIntro _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -795,6 +809,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" + XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" + XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -853,6 +869,8 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] + XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] + XGrpLinkMem profile -> o ["profile" .= profile] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] XGrpMemIntro memInfo -> o ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 0b296b17e8..bddca0deb4 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -31,6 +31,7 @@ module Simplex.Chat.Store.Groups getGroupAndMember, createNewGroup, createGroupInvitation, + createGroupInvitedViaLink, setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, @@ -59,6 +60,8 @@ module Simplex.Chat.Store.Groups getGroupInvitation, createNewContactMember, createNewContactMemberAsync, + createAcceptedMember, + createAcceptedMemberConnection, getContactViaMember, setNewContactMemberConnRequest, getMemberInvitation, @@ -102,6 +105,9 @@ module Simplex.Chat.Store.Groups createMemberContactInvited, updateMemberContactInvited, resetMemberContactFields, + updateMemberProfile, + getXGrpLinkMemReceived, + setXGrpLinkMemReceived, ) where @@ -412,6 +418,54 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ) pure $ Right incognitoLdn +createGroupInvitedViaLink :: DB.Connection -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink + db + user@User {userId, userContactId} + Connection {connId, customUserProfileId} + GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile} = do + currentTs <- liftIO getCurrentTime + groupId <- insertGroup_ currentTs + hostMemberId <- insertHost_ currentTs groupId + liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) + -- using IBUnknown since host is created without contact + void $ createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs + liftIO $ setViaGroupLinkHash db groupId connId + (,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId + where + insertGroup_ currentTs = ExceptT $ do + let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + liftIO $ do + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + "INSERT INTO groups (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?)" + (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs) + insertedRowId db + insertHost_ currentTs groupId = ExceptT $ do + let fromMemberProfile = profileFromName fromMemberName + withLocalDisplayName db userId fromMemberName $ \localDisplayName -> runExceptT $ do + (_, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + let MemberIdRole {memberId, memberRole} = fromMember + liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + ) + insertedRowId db + setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () setViaGroupLinkHash db groupId connId = DB.execute @@ -713,6 +767,47 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) ) +createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) +createAcceptedMember + db + gVar + User {userId, userContactId} + GroupInfo {groupId} + UserContactRequest {localDisplayName, profileId} + memberRole = do + liftIO $ + DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + createWithRandomId gVar $ \memId -> do + createdAt <- liftIO getCurrentTime + insertMember_ (MemberId memId) createdAt + groupMemberId <- liftIO $ insertedRowId db + pure (groupMemberId, MemberId memId) + where + insertMember_ memberId createdAt = + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) + ) + +createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createAcceptedMemberConnection + db + user@User {userId} + (cmdId, agentConnId) + UserContactRequest {cReqChatVRange, userContactLinkId} + groupMemberId + subMode = do + createdAt <- liftIO getCurrentTime + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode + setCommandConnId db user cmdId connId + getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do contactId <- @@ -768,9 +863,9 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo memInfo memCategory memStatus = do +createNewGroupMember db user gInfo memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs let newMember = NewGroupMember { memInfo, @@ -783,8 +878,8 @@ createNewGroupMember db user gInfo memInfo memCategory memStatus = do } liftIO $ createNewMember_ db user gInfo newMember currentTs -createNewMemberProfile_ :: DB.Connection -> User -> MemberInfo -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) -createNewMemberProfile_ db User {userId} (MemberInfo _ _ _ Profile {displayName, fullName, image, contactLink, preferences}) createdAt = +createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) +createNewMemberProfile_ db User {userId} Profile {displayName, fullName, image, contactLink, preferences} createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -960,7 +1055,7 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} Nothing -> do - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs @@ -1737,3 +1832,36 @@ createMemberContactConn_ connId <- insertedRowId db setCommandConnId db user cmdId connId pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + +updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db User {userId} m p' + | displayName == newName = do + liftIO $ updateContactProfile_ db userId profileId p' + pure m {memberProfile = profile} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateContactProfile_' db userId profileId p' currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + pure $ Right m {localDisplayName = ldn, memberProfile = profile} + where + GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m + Profile {displayName = newName} = p' + profile = toLocalProfile profileId p' localAlias + +getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool +getXGrpLinkMemReceived db mId = + ExceptT . firstRow fromOnly (SEGroupMemberNotFound mId) $ + DB.query db "SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?" (Only mId) + +setXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> Bool -> IO () +setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" + (xGrpLinkMemReceived, currentTs, mId) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 357bfd9a28..9335ae90e6 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -86,6 +86,7 @@ import Simplex.Chat.Migrations.M20231002_conn_initiated import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash import Simplex.Chat.Migrations.M20231010_member_settings import Simplex.Chat.Migrations.M20231019_indexes +import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -171,7 +172,8 @@ schemaMigrations = ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash), ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), - ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes) + ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes), + ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 10b0c91d04..23ed608639 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -515,6 +515,10 @@ instance ToJSON Profile where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +profileFromName :: ContactName -> Profile +profileFromName displayName = + Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} + -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool profilesMatch @@ -621,6 +625,18 @@ instance ToJSON GroupInvitation where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data GroupLinkInvitation = GroupLinkInvitation + { fromMember :: MemberIdRole, + fromMemberName :: ContactName, + invitedMember :: MemberIdRole, + groupProfile :: GroupProfile + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupLinkInvitation where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 7f1e1f5c7a..8494a7fc18 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -160,6 +160,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] + CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] @@ -176,6 +177,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CRGroupMemberUpdated {} -> [] CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci @@ -235,6 +237,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] + CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 3e1c32a6f7..36b990ba35 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -195,10 +195,10 @@ testSuspendResume tmp = testJoinGroup :: HasCallStack => FilePath -> IO () testJoinGroup tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> - withNewTestChat tmp "dan" danProfile $ \dan -> do + withDirectoryServiceCfg tmp testCfgGroupLinkViaContact $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> + withNewTestChatCfg tmp testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index fae460e908..ea455a0fc1 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -146,6 +146,16 @@ mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} groupCreateDirectVRange :: VersionRange groupCreateDirectVRange = mkVersionRange 1 1 +testCfgGroupLinkViaContact :: ChatConfig +testCfgGroupLinkViaContact = + mkCfgGroupLinkViaContact testCfg + +mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig +mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} + +groupLinkViaContactVRange :: VersionRange +groupLinkViaContactVRange = mkVersionRange 1 2 + createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey MCError diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 97b7491067..36b5cf4ea5 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -64,6 +64,16 @@ chatGroupTests = do it "own group link" testPlanGroupLinkOwn it "connecting via group link" testPlanGroupLinkConnecting it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin + describe "group links without contact" $ do + it "join via group link without creating contact" testGroupLinkNoContact + it "group link member role" testGroupLinkNoContactMemberRole + it "host incognito" testGroupLinkNoContactHostIncognito + it "invitee incognito" testGroupLinkNoContactInviteeIncognito + it "host profile received" testGroupLinkNoContactHostProfileReceived + it "existing contact merged" testGroupLinkNoContactExistingContactMerged + describe "group links without contact connection plan" $ do + it "group link without contact - known group" testPlanGroupLinkNoContactKnown + it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting describe "group message errors" $ do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet @@ -280,7 +290,7 @@ testGroupShared alice bob cath checkMessages = do testNewGroupIncognito :: HasCallStack => FilePath -> IO () testNewGroupIncognito = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob @@ -1735,7 +1745,7 @@ testGroupAsync tmp = do testGroupLink :: HasCallStack => FilePath -> IO () testGroupLink = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/g team" alice <## "group #team is created" @@ -1836,7 +1846,7 @@ testGroupLink = testGroupLinkDeleteGroupRejoin :: HasCallStack => FilePath -> IO () testGroupLinkDeleteGroupRejoin = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -1892,7 +1902,7 @@ testGroupLinkDeleteGroupRejoin = testGroupLinkContactUsed :: HasCallStack => FilePath -> IO () testGroupLinkContactUsed = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -1925,7 +1935,7 @@ testGroupLinkContactUsed = testGroupLinkIncognitoMembership :: HasCallStack => FilePath -> IO () testGroupLinkIncognitoMembership = - testChat4 aliceProfile bobProfile cathProfile danProfile $ + testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- bob connected incognito to alice alice ##> "/c" @@ -2098,7 +2108,7 @@ testGroupLinkUnusedHostContactDeleted = (bob TestCC -> TestCC -> String -> IO () bobLeaveDeleteGroup alice bob group = do bob ##> ("/l " <> group) @@ -2136,7 +2146,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = (bob TestCC -> TestCC -> String -> String -> IO String createGroupBobIncognito alice bob group bobsAliceContact = do alice ##> ("/g " <> group) @@ -2174,7 +2184,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = testGroupLinkMemberRole :: HasCallStack => FilePath -> IO () testGroupLinkMemberRole = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/g team" alice <## "group #team is created" @@ -2309,7 +2319,7 @@ testGroupLinkLeaveDelete = testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () testPlanGroupLinkOkKnown = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -2352,7 +2362,7 @@ testPlanGroupLinkOkKnown = testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () testPlanHostContactDeletedGroupLinkKnown = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -2398,7 +2408,7 @@ testPlanHostContactDeletedGroupLinkKnown = testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () testPlanGroupLinkOwn tmp = - withNewTestChat tmp "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp testCfgGroupLinkViaContact "alice" aliceProfile $ \alice -> do alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" @@ -2458,13 +2468,13 @@ testPlanGroupLinkOwn tmp = testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () testPlanGroupLinkConnecting tmp = do - gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChat tmp "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -2478,13 +2488,13 @@ testPlanGroupLinkConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChat tmp "alice" $ \alice -> do + withTestChatCfg tmp cfg "alice" $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChat tmp "bob" $ \bob -> do + withTestChatCfg tmp cfg "bob" $ \bob -> do threadDelay 500000 bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting" @@ -2495,10 +2505,12 @@ testPlanGroupLinkConnecting tmp = do bob ##> ("/c " <> gLink) bob <## "group link: connecting" + where + cfg = testCfgGroupLinkViaContact testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () testPlanGroupLinkLeaveRejoin = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -2578,6 +2590,296 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team_1" bob <## "use #team_1 to send messages" +testGroupLinkNoContact :: HasCallStack => FilePath -> IO () +testGroupLinkNoContact = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + +testGroupLinkNoContactMemberRole :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactMemberRole = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team observer" + gLink <- getGroupLink alice "team" GRObserver True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + + alice ##> "/ms team" + alice + <### [ "alice (Alice): owner, you, created group", + "bob (Bob): observer, invited, connected" + ] + + bob ##> "/ms team" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): observer, you, connected" + ] + + bob ##> "#team hi there" + bob <## "#team: you don't have permission to send messages" + + alice ##> "/mr #team bob member" + alice <## "#team: you changed the role of bob from observer to member" + bob <## "#team: alice changed your role from observer to member" + + bob #> "#team hey now" + alice <# "#team bob> hey now" + +testGroupLinkNoContactHostIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g i team" + aliceIncognito <- getTermLine alice + alice <## ("group #team is created, your incognito profile for this group is " <> aliceIncognito) + alice <## "to add members use /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice ?#> "#team hello" + bob <# ("#team " <> aliceIncognito <> "> hello") + bob #> "#team hi there" + alice ?<# "#team bob> hi there" + +testGroupLinkNoContactInviteeIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactInviteeIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c i " <> gLink) + bobIncognito <- getTermLine bob + bob <## "connection request sent incognito!" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice #> "#team hello" + bob ?<# "#team alice> hello" + bob ?#> "#team hi there" + alice <# ("#team " <> bobIncognito <> "> hi there") + +testGroupLinkNoContactHostProfileReceived :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostProfileReceived = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + let profileImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" + alice ##> ("/set profile image " <> profileImage) + alice <## "profile image updated" + + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + + aliceImage <- getProfilePictureByName bob "alice" + aliceImage `shouldBe` Just profileImage + +testGroupLinkNoContactExistingContactMerged :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactExistingContactMerged = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "#team: bob_1 joined the group" + alice <## "contact and member are merged: bob, #team bob_1" + alice <## "use @bob to send messages", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + bob <## "contact and member are merged: alice, #team alice_1" + bob <## "use @alice to send messages" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice <##> bob + + alice @@@ [("#team", "connected"), ("@bob", "hey")] + bob @@@ [("#team", "connected"), ("@alice", "hey")] + alice ##> "/contacts" + alice <## "bob (Bob)" + bob ##> "/contacts" + bob <## "alice (Alice)" + + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + +testPlanGroupLinkNoContactKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanGroupLinkNoContactConnecting :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactConnecting tmp = do + gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + getGroupLink alice "team" GRMember True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting, allowed to reconnect" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting, allowed to reconnect" + + threadDelay 100000 + withTestChat tmp "alice" $ \alice -> do + alice + <### [ "1 group links active", + "#team: group is empty", + "bob (Bob): accepting request to join group #team..." + ] + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob <## "#team: joining the group..." + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting to group #team" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting to group #team" + + bob ##> ("/c " <> gLink) + bob <## "group link: connecting to group #team" + testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -3183,7 +3485,7 @@ testMergeContactMultipleMembers = testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () testMergeGroupLinkHostMultipleContacts = - testChat2 bobProfile cathProfile $ + testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ \bob cath -> do connectUsers bob cath @@ -3412,7 +3714,7 @@ testMemberContactInvitedConnectionReplaced tmp = do testMemberContactIncognito :: HasCallStack => FilePath -> IO () testMemberContactIncognito = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do -- create group, bob joins incognito alice ##> "/g team" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index fcb6b65e41..f0f47978b5 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -17,12 +17,14 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T +import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) import Simplex.Chat.Protocol import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences -import Simplex.Messaging.Agent.Store.SQLite (withTransaction) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) @@ -433,6 +435,12 @@ getContactProfiles cc = do profiles <- withTransaction (chatStore $ chatController cc) $ \db -> getUserContactProfiles db user pure $ map (\Profile {displayName} -> displayName) profiles +getProfilePictureByName :: TestCC -> String -> IO (Maybe String) +getProfilePictureByName cc displayName = + withTransaction (chatStore $ chatController cc) $ \db -> + maybeFirstRow fromOnly $ + DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName) + lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 0b99c5a4d8..f5c1bf8560 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -122,7 +122,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-2\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-3\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -232,13 +232,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" @@ -250,7 +250,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From 42458a2715ffee6a4cd4cd8b30495027d3f283ac Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:51:02 +0400 Subject: [PATCH 72/80] ios, android: process new group link events (#3293) --- apps/ios/Shared/Model/SimpleXAPI.swift | 16 ++++++++++++++ apps/ios/SimpleXChat/APITypes.swift | 6 ++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 21 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index de09853e12..3ace6735f1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1362,6 +1362,12 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateChatInfo(cInfo) } } + case let .groupMemberUpdated(user, groupInfo, _, toMember): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, toMember) + } + } case let .contactsMerged(user, intoContact, mergedContact): if active(user) && m.hasChat(mergedContact.id) { await MainActor.run { @@ -1475,6 +1481,16 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(hostContact.activeConn.id) } } + case let .groupLinkConnecting(user, groupInfo, hostMember): + if !active(user) { return } + + await MainActor.run { + m.updateGroup(groupInfo) + if let hostConn = hostMember.activeConn { + m.dismissConnReqView(hostConn.id) + m.removeChat(hostConn.id) + } + } case let .joinedGroupMemberConnecting(user, groupInfo, _, member): if active(user) { await MainActor.run { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index e270674784..4afe2583ca 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -496,6 +496,7 @@ public enum ChatResponse: Decodable, Error { case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef) case contactUpdated(user: UserRef, toContact: Contact) + case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) // TODO remove events below case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef]) @@ -518,6 +519,7 @@ public enum ChatResponse: Decodable, Error { case groupCreated(user: UserRef, groupInfo: GroupInfo) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) + case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: Group) @@ -638,6 +640,7 @@ public enum ChatResponse: Decodable, Error { case .acceptingContactRequest: return "acceptingContactRequest" case .contactRequestRejected: return "contactRequestRejected" case .contactUpdated: return "contactUpdated" + case .groupMemberUpdated: return "groupMemberUpdated" case .contactsSubscribed: return "contactsSubscribed" case .contactsDisconnected: return "contactsDisconnected" case .contactSubSummary: return "contactSubSummary" @@ -657,6 +660,7 @@ public enum ChatResponse: Decodable, Error { case .groupCreated: return "groupCreated" case .sentGroupInvitation: return "sentGroupInvitation" case .userAcceptedGroupSent: return "userAcceptedGroupSent" + case .groupLinkConnecting: return "groupLinkConnecting" case .userDeletedMember: return "userDeletedMember" case .leftMemberUser: return "leftMemberUser" case .groupMembers: return "groupMembers" @@ -777,6 +781,7 @@ public enum ChatResponse: Decodable, Error { case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) @@ -796,6 +801,7 @@ public enum ChatResponse: Decodable, Error { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b751cb56c5..7ce508f662 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1443,6 +1443,11 @@ object ChatController { chatModel.updateChatInfo(cInfo) } } + is CR.GroupMemberUpdated -> { + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.toMember) + } + } is CR.ContactsMerged -> { if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { @@ -1553,6 +1558,16 @@ object ChatController { chatModel.removeChat(r.hostContact.activeConn.id) } } + is CR.GroupLinkConnecting -> { + if (!active(r.user)) return + + chatModel.updateGroup(r.groupInfo) + val hostConn = r.hostMember.activeConn + if (hostConn != null) { + chatModel.dismissConnReqView(hostConn.id) + chatModel.removeChat(hostConn.id) + } + } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { chatModel.upsertGroupMember(r.groupInfo, r.member) @@ -3379,6 +3394,7 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() + @Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR() // TODO remove below @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() @@ -3401,6 +3417,7 @@ sealed class CR { @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() + @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @@ -3515,6 +3532,7 @@ sealed class CR { is AcceptingContactRequest -> "acceptingContactRequest" is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" + is GroupMemberUpdated -> "groupMemberUpdated" is ContactsSubscribed -> "contactsSubscribed" is ContactsDisconnected -> "contactsDisconnected" is ContactSubSummary -> "contactSubSummary" @@ -3534,6 +3552,7 @@ sealed class CR { is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" + is GroupLinkConnecting -> "groupLinkConnecting" is UserDeletedMember -> "userDeletedMember" is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" @@ -3646,6 +3665,7 @@ sealed class CR { is AcceptingContactRequest -> withUser(user, json.encodeToString(contact)) is ContactRequestRejected -> withUser(user, noDetails()) is ContactUpdated -> withUser(user, json.encodeToString(toContact)) + is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember") is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) @@ -3665,6 +3685,7 @@ sealed class CR { is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) + is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) From 07173f7b2f45df4213f4c0573fc7026d4cb623f2 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:51:20 +0400 Subject: [PATCH 73/80] core: add delays to testXFTPMarkToReceive test (#3294) --- tests/ChatTests/Files.hs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 5231283903..03a5d6acfb 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -1393,10 +1393,16 @@ testXFTPMarkToReceive = do alice <## "completed uploading file 1 (test.pdf) for bob" bob #$> ("/_set_file_to_receive 1", id, "ok") + threadDelay 100000 + bob ##> "/_stop" bob <## "chat stopped" + bob #$> ("/_files_folder ./tests/tmp/bob_files", id, "ok") bob #$> ("/_temp_folder ./tests/tmp/bob_xftp", id, "ok") + + threadDelay 100000 + bob ##> "/_start" bob <## "chat started" From 9e8084874fd2252ff2ecdab0fff1d767f5bac9d1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:44:57 +0000 Subject: [PATCH 74/80] ios: block members (#3248) * ios: block members (WIP) * CIBlocked, blocking api * show item as blocked * show blocked and merge multiple deleted items * update block icons * split sent and received deleted to two categories * merge chat feature items, refactor CIMergedRange * merge feature items, two profile images and names on merged items * ensure range is withing chat items range * merge group events * fix/refactor * make group member changes observable * exclude some group events from merging * fix states not updating and other fixes * load list of members when tapping profile * refactor * fix incorrect merging of sent/received marked deleted * fix incorrect expand/hide on single moderated items without content * load members list when opening member via item * comments * fix member counting in case of name collision --- apps/ios/Shared/AppDelegate.swift | 1 - apps/ios/Shared/Model/ChatModel.swift | 100 +++- apps/ios/Shared/Model/SimpleXAPI.swift | 11 +- .../Shared/Views/Call/CallController.swift | 2 - .../Views/Chat/ChatItem/CICallItemView.swift | 4 +- .../Chat/ChatItem/CIChatFeatureView.swift | 78 ++- .../Views/Chat/ChatItem/CIEventView.swift | 8 +- .../ChatItem/CIFeaturePreferenceView.swift | 5 +- .../Views/Chat/ChatItem/CIFileView.swift | 24 +- .../Views/Chat/ChatItem/CIImageView.swift | 3 +- .../ChatItem/CIMemberCreatedContactView.swift | 3 +- .../Views/Chat/ChatItem/CIMetaView.swift | 17 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 17 +- .../Views/Chat/ChatItem/CIVideoView.swift | 9 +- .../Views/Chat/ChatItem/CIVoiceView.swift | 15 +- .../Views/Chat/ChatItem/DeletedItemView.swift | 7 +- .../Views/Chat/ChatItem/EmojiItemView.swift | 7 +- .../Chat/ChatItem/FramedCIVoiceView.swift | 11 +- .../Views/Chat/ChatItem/FramedItemView.swift | 80 +-- .../Chat/ChatItem/FullScreenMediaView.swift | 2 +- .../ChatItem/IntegrityErrorItemView.swift | 8 +- .../Chat/ChatItem/MarkedDeletedItemView.swift | 65 ++- .../Views/Chat/ChatItem/MsgContentView.swift | 3 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 5 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 123 +++-- apps/ios/Shared/Views/Chat/ChatView.swift | 470 ++++++++++++------ .../Chat/ComposeMessage/ComposeView.swift | 2 + .../Chat/ComposeMessage/ContextItemView.swift | 4 +- .../Chat/Group/AddGroupMembersView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 174 +++++-- .../Chat/Group/GroupMemberInfoView.swift | 118 ++++- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../ChatList/ContactConnectionInfo.swift | 2 +- .../Views/Helpers/VideoPlayerView.swift | 1 - .../Shared/Views/NewChat/AddContactView.swift | 2 +- .../Shared/Views/NewChat/AddGroupView.swift | 2 +- .../Views/UserSettings/PrivacySettings.swift | 2 +- apps/ios/SimpleXChat/APITypes.swift | 3 + apps/ios/SimpleXChat/ChatTypes.swift | 56 ++- 39 files changed, 1001 insertions(+), 447 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 01afa18a15..39f5df636c 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -55,7 +55,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { logger.debug("AppDelegate: didReceiveRemoteNotification") - print("*** userInfo", userInfo) let m = ChatModel.shared if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], m.notificationMode != .off { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3cc52d502d..f562ea7f51 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -64,7 +64,7 @@ final class ChatModel: ObservableObject { @Published var reversedChatItems: [ChatItem] = [] var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? - @Published var groupMembers: [GroupMember] = [] + @Published var groupMembers: [GMember] = [] // items in the terminal view @Published var showingTerminal = false @Published var terminalItems: [TerminalItem] = [] @@ -163,6 +163,10 @@ final class ChatModel: ObservableObject { } } + func getGroupMember(_ groupMemberId: Int64) -> GMember? { + groupMembers.first { $0.groupMemberId == groupMemberId } + } + private func getChatIndex(_ id: String) -> Int? { chats.firstIndex(where: { $0.id == id }) } @@ -176,6 +180,7 @@ final class ChatModel: ObservableObject { func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { chats[i].chatInfo = cInfo + chats[i].created = Date.now } } @@ -339,7 +344,7 @@ final class ChatModel: ObservableObject { reversedChatItems[i].viewTimestamp = .now } - private func getChatItemIndex(_ cItem: ChatItem) -> Int? { + func getChatItemIndex(_ cItem: ChatItem) -> Int? { reversedChatItems.firstIndex(where: { $0.id == cItem.id }) } @@ -528,27 +533,62 @@ final class ChatModel: ObservableObject { users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) } - func getConnectedMemberNames(_ ci: ChatItem) -> [String] { - guard var i = getChatItemIndex(ci) else { return [] } + // this function analyses "connected" events and assumes that each member will be there only once + func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) { + var count = 0 var ns: [String] = [] - while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected { - ns.append(m.displayName) - i += 1 + if let ciCategory = chatItem.mergeCategory, + var i = getChatItemIndex(chatItem) { + while i < reversedChatItems.count { + let ci = reversedChatItems[i] + if ci.mergeCategory != ciCategory { break } + if let m = ci.memberConnected { + ns.append(m.displayName) + } + count += 1 + i += 1 + } } - return ns + return (count, ns) } - func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) { - if let i = getChatItemIndex(ci) { - return ( - i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil, - i - 1 >= 0 ? reversedChatItems[i - 1] : nil - ) + // returns the index of the passed item and the next item (it has smaller index) + func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { + if let i = getChatItemIndex(ci) { + (i, i > 0 ? reversedChatItems[i - 1] : nil) } else { - return (nil, nil) + (nil, nil) } } + // returns the index of the first item in the same merged group (the first hidden item) + // and the previous visible item with another merge category + func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) { + guard var i = ciIndex else { return (nil, nil) } + let fst = reversedChatItems.count - 1 + while i < fst { + i = i + 1 + let ci = reversedChatItems[i] + if ciCategory == nil || ciCategory != ci.mergeCategory { + return (i - 1, ci) + } + } + return (i, nil) + } + + // returns the previous member in the same merge group and the count of members in this group + func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange) -> (GroupMember?, Int) { + var prevMember: GroupMember? = nil + var memberIds: Set = [] + for i in range { + if case let .groupRcv(m) = reversedChatItems[i].chatDir { + if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } + memberIds.insert(m.groupMemberId) + } + } + return (prevMember, memberIds.count) + } + func popChat(_ id: String) { if let i = getChatIndex(id) { popChat_(i) @@ -583,13 +623,14 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == groupInfo.id { - if let i = groupMembers.firstIndex(where: { $0.id == member.id }) { + if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { withAnimation(.default) { - self.groupMembers[i] = member + self.groupMembers[i].wrapped = member + self.groupMembers[i].created = Date.now } return false } else { - withAnimation { groupMembers.append(member) } + withAnimation { groupMembers.append(GMember(member)) } return true } } else { @@ -598,11 +639,10 @@ final class ChatModel: ObservableObject { } func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) { - if let conn = member.activeConn { - var updatedConn = conn - updatedConn.connectionStats = connectionStats + if var conn = member.activeConn { + conn.connectionStats = connectionStats var updatedMember = member - updatedMember.activeConn = updatedConn + updatedMember.activeConn = conn _ = upsertGroupMember(groupInfo, updatedMember) } } @@ -700,3 +740,19 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } + +final class GMember: ObservableObject, Identifiable { + @Published var wrapped: GroupMember + var created = Date.now + + init(_ member: GroupMember) { + self.wrapped = member + } + + var id: String { wrapped.id } + var groupId: Int64 { wrapped.groupId } + var groupMemberId: Int64 { wrapped.groupMemberId } + var displayName: String { wrapped.displayName } + var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } } + static let sampleData = GMember(GroupMember.sampleData) +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3ace6735f1..32e983a841 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -502,6 +502,10 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) } +func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSettings: GroupMemberSettings) async throws { + try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings)) +} + func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } @@ -1079,8 +1083,8 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] { return [] } -func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { - let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil } +func filterMembersToAdd(_ ms: [GMember]) -> [Contact] { + let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } return ChatModel.shared.chats .compactMap{ $0.chatInfo.contact } .filter{ !memberContactIds.contains($0.apiId) } @@ -1550,10 +1554,11 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateGroup(toGroup) } } - case let .memberRole(user, groupInfo, _, _, _, _): + case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, member) } } case let .newMemberContactReceivedInv(user, contact, _, _): diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 6a20eee59a..9ca894ea89 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -108,7 +108,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse try audioSession.setActive(true) logger.debug("audioSession activated") } catch { - print(error) logger.error("failed activating audio session") } } @@ -121,7 +120,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse try audioSession.setActive(false) logger.debug("audioSession deactivated") } catch { - print(error) logger.error("failed deactivating audio session") } suspendOnEndCall() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 28740a8cf4..f0bf43dffe 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -11,7 +11,7 @@ import SimpleXChat struct CICallItemView: View { @EnvironmentObject var m: ChatModel - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem var status: CICallStatus var duration: Int @@ -60,7 +60,7 @@ struct CICallItemView: View { @ViewBuilder private func acceptCallButton() -> some View { - if case let .direct(contact) = chatInfo { + if case let .direct(contact) = chat.chatInfo { Button { if let invitation = m.callInvitations[contact.id] { CallController.shared.answerCall(invitation: invitation) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index a7bff2f421..03afa30331 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -10,20 +10,92 @@ import SwiftUI import SimpleXChat struct CIChatFeatureView: View { + @EnvironmentObject var m: ChatModel var chatItem: ChatItem + @Binding var revealed: Bool var feature: Feature var icon: String? = nil var iconColor: Color var body: some View { + if !revealed, let fs = mergedFeautures() { + HStack { + ForEach(fs, content: featureIconView) + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + } else { + fullFeatureView + } + } + + private struct FeatureInfo: Identifiable { + var icon: String + var scale: CGFloat + var color: Color + var param: String? + + init(_ f: Feature, _ color: Color, _ param: Int?) { + self.icon = f.iconFilled + self.scale = f.iconScale + self.color = color + self.param = f.hasParam && param != nil ? timeText(param) : nil + } + + var id: String { + "\(icon) \(color) \(param ?? "")" + } + } + + private func mergedFeautures() -> [FeatureInfo]? { + var fs: [FeatureInfo] = [] + var icons: Set = [] + if var i = m.getChatItemIndex(chatItem) { + while i < m.reversedChatItems.count, + let f = featureInfo(m.reversedChatItems[i]) { + if !icons.contains(f.icon) { + fs.insert(f, at: 0) + icons.insert(f.icon) + } + i += 1 + } + } + return fs.count > 1 ? fs : nil + } + + private func featureInfo(_ ci: ChatItem) -> FeatureInfo? { + switch ci.content { + case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) + case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) + case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + default: nil + } + } + + @ViewBuilder private func featureIconView(_ f: FeatureInfo) -> some View { + let i = Image(systemName: f.icon) + .foregroundColor(f.color) + .scaleEffect(f.scale) + if let param = f.param { + HStack { + i + chatEventText(Text(param)).lineLimit(1) + } + } else { + i + } + } + + private var fullFeatureView: some View { HStack(alignment: .bottom, spacing: 4) { Image(systemName: icon ?? feature.iconFilled) .foregroundColor(iconColor) .scaleEffect(feature.iconScale) chatEventText(chatItem) } - .padding(.leading, 6) - .padding(.bottom, 6) + .padding(.horizontal, 6) + .padding(.vertical, 4) .textSelection(.disabled) } } @@ -31,6 +103,6 @@ struct CIChatFeatureView: View { struct CIChatFeatureView_Previews: PreviewProvider { static var previews: some View { let enabled = FeatureEnabled(forUser: false, forContact: false) - CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) + CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift index 9b372154ed..b6be3f837e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift @@ -13,11 +13,9 @@ struct CIEventView: View { var eventText: Text var body: some View { - HStack(alignment: .bottom, spacing: 0) { - eventText - } - .padding(.leading, 6) - .padding(.bottom, 6) + eventText + .padding(.horizontal, 6) + .padding(.vertical, 4) .textSelection(.disabled) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index bb38cc872a..e52a92a3c6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct CIFeaturePreferenceView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var chatItem: ChatItem var feature: ChatFeature var allowed: FeatureAllowed @@ -80,7 +80,6 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider { quotedItem: nil, file: nil ) - CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) - .environmentObject(Chat.sampleData) + CIFeaturePreferenceView(chat: Chat.sampleData, chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 359633a5f5..4ae2296f46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIFileView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme let file: CIFile? let edited: Bool @@ -83,7 +84,7 @@ struct CIFileView: View { if fileSizeValid() { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { let encrypted = privacyEncryptLocalFilesGroupDefault.get() await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) } @@ -234,18 +235,17 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index bb43179577..9ae52ae01b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIImageView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme let chatItem: ChatItem let image: String @@ -36,7 +37,7 @@ struct CIImageView: View { switch file.fileStatus { case .rcvInvitation: Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index a204d83f1e..da82ed4dd2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIMemberCreatedContactView: View { + @EnvironmentObject var m: ChatModel var chatItem: ChatItem var body: some View { @@ -21,7 +22,7 @@ struct CIMemberCreatedContactView: View { .onTapGesture { dismissAllSheets(animated: true) DispatchQueue.main.async { - ChatModel.shared.chatId = "@\(contactId)" + m.chatId = "@\(contactId)" } } } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 30430dc19a..c189abde24 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct CIMetaView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var chatItem: ChatItem var metaColor = Color.secondary var paleMetaColor = Color(UIColor.tertiaryLabel) @@ -95,15 +95,14 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text { struct CIMetaView_Previews: PreviewProvider { static var previews: some View { Group { - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true)) - CIMetaView(chatItem: ChatItem.getDeletedContentSample()) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true)) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) } .previewLayout(.fixed(width: 360, height: 100)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index e1a5c252ec..f276025dda 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -12,7 +12,8 @@ import SimpleXChat let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." struct CIRcvDecryptionError: View { - @EnvironmentObject var chat: Chat + @EnvironmentObject var m: ChatModel + @ObservedObject var chat: Chat var msgDecryptError: MsgDecryptError var msgCount: UInt32 var chatItem: ChatItem @@ -45,7 +46,7 @@ struct CIRcvDecryptionError: View { do { let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId) if let s = stats { - ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s) + m.updateGroupMemberConnectionStats(groupInfo, member, s) } } catch let error { logger.error("apiGroupMemberInfo error: \(responseError(error))") @@ -79,8 +80,8 @@ struct CIRcvDecryptionError: View { } } else if case let .group(groupInfo) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir, - let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }), - let memberStats = modelMember.activeConn?.connectionStats { + let mem = m.getGroupMember(groupMember.groupMemberId), + let memberStats = mem.wrapped.activeConn?.connectionStats { if memberStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) } @@ -122,7 +123,7 @@ struct CIRcvDecryptionError: View { ) } .padding(.horizontal, 12) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .onTapGesture(perform: { onClick() }) @@ -142,7 +143,7 @@ struct CIRcvDecryptionError: View { + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) } .padding(.horizontal, 12) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .onTapGesture(perform: { onClick() }) @@ -173,7 +174,7 @@ struct CIRcvDecryptionError: View { do { let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false) await MainActor.run { - ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats) + m.updateGroupMemberConnectionStats(groupInfo, mem, stats) } } catch let error { logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))") @@ -190,7 +191,7 @@ struct CIRcvDecryptionError: View { do { let stats = try apiSyncContactRatchet(contact.apiId, false) await MainActor.run { - ChatModel.shared.updateContactConnectionStats(contact, stats) + m.updateContactConnectionStats(contact, stats) } } catch let error { logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 3807a11b4e..bc7153ed47 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -11,6 +11,7 @@ import AVKit import SimpleXChat struct CIVideoView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme private let chatItem: ChatItem private let image: String @@ -101,7 +102,7 @@ struct CIVideoView: View { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete VideoPlayerView(player: player, url: url, showControls: false) .frame(width: w, height: w * preview.size.height / preview.size.width) - .onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in + .onChange(of: m.stopPreviousRecPlay) { playingUrl in if playingUrl != url { player.pause() videoPlaying = false @@ -124,7 +125,7 @@ struct CIVideoView: View { } if !videoPlaying { Button { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url player.play() } label: { playPauseIcon(canBePlayed ? "play.fill" : "play.slash") @@ -256,7 +257,7 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await receiveFile(user, file.fileId, encrypted, false) } } @@ -290,7 +291,7 @@ struct CIVideoView: View { ) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url if let player = fullPlayer { player.play() fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index b0875abe8d..2e54ba4143 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIVoiceView: View { + @ObservedObject var chat: Chat var chatItem: ChatItem let recordingFile: CIFile? let duration: Int @@ -91,7 +92,7 @@ struct CIVoiceView: View { } private func metaView() -> some View { - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) } } @@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile) -> some View { Button { Task { - if let user = ChatModel.shared.currentUser { + if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } } @@ -284,6 +285,7 @@ struct CIVoiceView_Previews: PreviewProvider { ) Group { CIVoiceView( + chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), duration: 30, @@ -292,12 +294,11 @@ struct CIVoiceView_Previews: PreviewProvider { playbackTime: .constant(TimeInterval(20)), allowMenu: Binding.constant(true) ) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index af4df40978..4763707421 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct DeletedItemView: View { @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat var chatItem: ChatItem var body: some View { @@ -18,7 +19,7 @@ struct DeletedItemView: View { Text(chatItem.content.text) .foregroundColor(.secondary) .italic() - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .padding(.leading, 12) @@ -32,8 +33,8 @@ struct DeletedItemView: View { struct DeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { - DeletedItemView(chatItem: ChatItem.getDeletedContentSample()) - DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) + DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) + DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index f5ae761e84..f57e45fed0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct EmojiItemView: View { + @ObservedObject var chat: Chat var chatItem: ChatItem var body: some View { @@ -17,7 +18,7 @@ struct EmojiItemView: View { emojiText(chatItem.content.text) .padding(.top, 8) .padding(.horizontal, 6) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.bottom, 8) .padding(.horizontal, 12) } @@ -32,8 +33,8 @@ func emojiText(_ text: String) -> Text { struct EmojiItemView_Previews: PreviewProvider { static var previews: some View { Group{ - EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) - EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 3f7ca3f836..af5c917dc8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -88,13 +88,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index aab0cd5f55..51dfa3cb50 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -15,9 +15,11 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) struct FramedItemView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var maxWidth: CGFloat = .infinity @State var scrollProxy: ScrollViewProxy? = nil @State var msgWidth: CGFloat = 0 @@ -35,9 +37,12 @@ struct FramedItemView: View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { if let di = chatItem.meta.itemDeleted { - if case let .moderated(_, byGroupMember) = di { - framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.chatViewName)").italic()) - } else { + switch di { + case let .moderated(_, byGroupMember): + framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) + case .blocked: + framedItemHeader(icon: "hand.raised", caption: Text("blocked").italic()) + default: framedItemHeader(icon: "trash", caption: Text("marked deleted").italic()) } } else if chatItem.meta.isLive { @@ -48,7 +53,7 @@ struct FramedItemView: View { ciQuoteView(qi) .onTapGesture { if let proxy = scrollProxy, - let ci = ChatModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { + let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { proxy.scrollTo(ci.viewId, anchor: .bottom) } @@ -56,14 +61,14 @@ struct FramedItemView: View { } } - ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: framedMsgContentView) + ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView) .padding(chatItem.content.msgContent != nil ? 0 : 4) .overlay(DetermineWidth()) } .onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 } if chatItem.content.msgContent != nil { - CIMetaView(chatItem: chatItem, metaColor: metaColor) + CIMetaView(chat: chat, chatItem: chatItem, metaColor: metaColor) .padding(.horizontal, 12) .padding(.bottom, 6) .overlay(DetermineWidth()) @@ -247,7 +252,7 @@ struct FramedItemView: View { } private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { - MsgContentView(text: qi.text, formattedText: qi.formattedText) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText) .lineLimit(lines) .font(.subheadline) .padding(.bottom, 6) @@ -264,7 +269,7 @@ struct FramedItemView: View { } private func membership() -> GroupMember? { - switch chatInfo { + switch chat.chatInfo { case let .group(groupInfo: groupInfo): return groupInfo.membership default: return nil } @@ -274,6 +279,7 @@ struct FramedItemView: View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let v = MsgContentView( + chat: chat, text: text, formattedText: text == "" ? [] : ci.formattedText, meta: ci.meta, @@ -356,14 +362,14 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Co struct FramedItemView_Previews: PreviewProvider { static var previews: some View { Group{ - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -372,16 +378,16 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -390,16 +396,16 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index be5b61b61a..0e721acdcb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -150,7 +150,7 @@ struct FullScreenMediaView: View { private func startPlayerAndNotify() { if let player = player { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url player.play() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 9908d4d104..1aa0093c9a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -10,11 +10,12 @@ import SwiftUI import SimpleXChat struct IntegrityErrorItemView: View { + @ObservedObject var chat: Chat var msgError: MsgErrorType var chatItem: ChatItem var body: some View { - CIMsgError(chatItem: chatItem) { + CIMsgError(chat: chat, chatItem: chatItem) { switch msgError { case .msgSkipped: AlertManager.shared.showAlertMsg( @@ -52,6 +53,7 @@ struct IntegrityErrorItemView: View { } struct CIMsgError: View { + @ObservedObject var chat: Chat var chatItem: ChatItem var onTap: () -> Void @@ -60,7 +62,7 @@ struct CIMsgError: View { Text(chatItem.content.text) .foregroundColor(.red) .italic() - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .padding(.leading, 12) @@ -74,6 +76,6 @@ struct CIMsgError: View { struct IntegrityErrorItemView_Previews: PreviewProvider { static var previews: some View { - IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) + IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 8e042a8c76..c6af95e6f6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -10,39 +10,70 @@ import SwiftUI import SimpleXChat struct MarkedDeletedItemView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var body: some View { - HStack(alignment: .bottom, spacing: 0) { - if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted { - markedDeletedText("moderated by \(byGroupMember.chatViewName)") - } else { - markedDeletedText("marked deleted") - } - CIMetaView(chatItem: chatItem) - .padding(.horizontal, 12) - } - .padding(.leading, 12) + (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 12) .padding(.vertical, 6) .background(chatItemFrameColor(chatItem, colorScheme)) .cornerRadius(18) .textSelection(.disabled) } - func markedDeletedText(_ s: LocalizedStringKey) -> some View { - Text(s) - .font(.caption) - .foregroundColor(.secondary) - .italic() - .lineLimit(1) + var mergedMarkedDeletedText: LocalizedStringKey { + if !revealed, + let ciCategory = chatItem.mergeCategory, + var i = m.getChatItemIndex(chatItem) { + var moderated = 0 + var blocked = 0 + var deleted = 0 + var moderatedBy: Set = [] + while i < m.reversedChatItems.count, + let ci = .some(m.reversedChatItems[i]), + ci.mergeCategory == ciCategory, + let itemDeleted = ci.meta.itemDeleted { + switch itemDeleted { + case let .moderated(_, byGroupMember): + moderated += 1 + moderatedBy.insert(byGroupMember.displayName) + case .blocked: blocked += 1 + case .deleted: deleted += 1 + } + i += 1 + } + let total = moderated + blocked + deleted + return total <= 1 + ? markedDeletedText + : total == moderated + ? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))" + : total == blocked + ? "\(total) messages blocked" + : "\(total) messages marked deleted" + } else { + return markedDeletedText + } + } + + var markedDeletedText: LocalizedStringKey { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + default: "marked deleted" + } } } struct MarkedDeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { - MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))) + MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 8b757ed1a3..d0d2bdf3dd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -25,7 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text { } struct MsgContentView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var text: String var formattedText: [FormattedText]? = nil var sender: String? = nil @@ -152,6 +152,7 @@ struct MsgContentView_Previews: PreviewProvider { static var previews: some View { let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") return MsgContentView( + chat: Chat.sampleData, text: chatItem.text, formattedText: chatItem.formattedText, sender: chatItem.memberDisplayName, diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 61802c61fe..83c4cdcda6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct ChatItemInfoView: View { + @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme var ci: ChatItem @Binding var chatItemInfo: ChatItemInfo? @@ -290,8 +291,8 @@ struct ChatItemInfoView: View { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] { memberDeliveryStatuses.compactMap({ mds in - if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) { - return (mem, mds.memberDeliveryStatus) + if let mem = chatModel.getGroupMember(mds.groupMemberId) { + return (mem.wrapped, mds.memberDeliveryStatus) } else { return nil } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 31fe19c39a..657df60654 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct ChatItemView: View { - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem var maxWidth: CGFloat = .infinity @State var scrollProxy: ScrollViewProxy? = nil @@ -19,8 +19,19 @@ struct ChatItemView: View { @Binding var audioPlayer: AudioPlayer? @Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackTime: TimeInterval? - init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding, allowMenu: Binding = .constant(false), audioPlayer: Binding = .constant(nil), playbackState: Binding = .constant(.noPlayback), playbackTime: Binding = .constant(nil)) { - self.chatInfo = chatInfo + init( + chat: Chat, + chatItem: ChatItem, + showMember: Bool = false, + maxWidth: CGFloat = .infinity, + scrollProxy: ScrollViewProxy? = nil, + revealed: Binding, + allowMenu: Binding = .constant(false), + audioPlayer: Binding = .constant(nil), + playbackState: Binding = .constant(.noPlayback), + playbackTime: Binding = .constant(nil) + ) { + self.chat = chat self.chatItem = chatItem self.maxWidth = maxWidth _scrollProxy = .init(initialValue: scrollProxy) @@ -33,15 +44,15 @@ struct ChatItemView: View { var body: some View { let ci = chatItem - if chatItem.meta.itemDeleted != nil && !revealed { - MarkedDeletedItemView(chatItem: chatItem) + if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { + MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed) } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { - EmojiItemView(chatItem: ci) + EmojiItemView(chat: chat, chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { - CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) + CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) } else if ci.content.msgContent == nil { - ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case + ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case } else { framedItemView() } @@ -51,14 +62,15 @@ struct ChatItemView: View { } private func framedItemView() -> some View { - FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) + FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) } } struct ChatItemContentView: View { @EnvironmentObject var chatModel: ChatModel - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var msgContentView: () -> Content @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -72,15 +84,14 @@ struct ChatItemContentView: View { case let .rcvCall(status, duration): callItemView(status, duration) case let .rcvIntegrityError(msgError): if developerTools { - IntegrityErrorItemView(msgError: msgError, chatItem: chatItem) + IntegrityErrorItemView(chat: chat, msgError: msgError, chatItem: chatItem) } else { ZStack {} } - case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) + case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case .rcvDirectEvent: eventItemView() - case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent: eventItemView() case .sndGroupEvent: eventItemView() @@ -89,9 +100,9 @@ struct ChatItemContentView: View { case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .rcvChatPreference(feature, allowed, param): - CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param) + CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary) + CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary) case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) @@ -103,15 +114,15 @@ struct ChatItemContentView: View { } private func deletedItemView() -> some View { - DeletedItemView(chatItem: chatItem) + DeletedItemView(chat: chat, chatItem: chatItem) } private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View { - CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration) + CICallItemView(chat: chat, chatItem: chatItem, status: status, duration: duration) } private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { - CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito) + CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito) } private func eventItemView() -> some View { @@ -119,7 +130,9 @@ struct ChatItemContentView: View { } private func eventItemViewText() -> Text { - if let member = chatItem.memberDisplayName { + if !revealed, let t = mergedGroupEventText { + return chatEventText(t + Text(" ") + chatItem.timestampText) + } else if let member = chatItem.memberDisplayName { return Text(member + " ") .font(.caption) .foregroundColor(.secondary) @@ -131,36 +144,44 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor) + CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) } - private var membersConnectedItemText: Text { - if let t = membersConnectedText { - return chatEventText(t, chatItem.timestampText) + private var mergedGroupEventText: Text? { + let (count, ns) = chatModel.getConnectedMemberNames(chatItem) + let members: LocalizedStringKey = + switch ns.count { + case 1: "\(ns[0]) connected" + case 2: "\(ns[0]) and \(ns[1]) connected" + case 3: "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected" + default: + ns.count > 3 + ? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected" + : "" + } + return if count <= 1 { + nil + } else if ns.count == 0 { + Text("\(count) group events") + } else if count > ns.count { + Text(members) + Text(" ") + Text("and \(count - ns.count) other events") } else { - return eventItemViewText() + Text(members) } } - - private var membersConnectedText: LocalizedStringKey? { - let ns = chatModel.getConnectedMemberNames(chatItem) - return ns.count > 3 - ? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected" - : ns.count == 3 - ? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected" - : ns.count == 2 - ? "\(ns[0]) and \(ns[1]) connected" - : nil - } } -func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text { - (Text(eventText) + Text(" ") + ts) +func chatEventText(_ text: Text) -> Text { + text .font(.caption) .foregroundColor(.secondary) .fontWeight(.light) } +func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text { + chatEventText(Text(eventText) + Text(" ") + ts) +} + func chatEventText(_ ci: ChatItem) -> Text { chatEventText("\(ci.content.text)", ci.timestampText) } @@ -168,15 +189,15 @@ func chatEventText(_ ci: ChatItem) -> Text { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 70)) .environmentObject(Chat.sampleData) @@ -188,7 +209,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -199,7 +220,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), @@ -210,7 +231,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -221,7 +242,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -232,7 +253,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5679b451a0..5e5a7f8b51 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -21,9 +21,7 @@ struct ChatView: View { @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() - @State private var deletingItem: ChatItem? = nil @State private var keyboardVisible = false - @State private var showDeleteMessage = false @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? @@ -36,7 +34,8 @@ struct ChatView: View { @State private var searchText: String = "" @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon - @State private var selectedMember: GroupMember? = nil + @State private var membersLoaded = false + @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @State private var showGroupLinkSheet: Bool = false @State private var groupLink: String? @@ -97,6 +96,8 @@ struct ChatView: View { if chatModel.chatId == nil { chatModel.chatItemStatuses = [:] chatModel.reversedChatItems = [] + chatModel.groupMembers = [] + membersLoaded = false } } } @@ -134,18 +135,21 @@ struct ChatView: View { } } else if case let .group(groupInfo) = cInfo { Button { - Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - showChatInfoSheet = true - } - } + Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } } } label: { ChatInfoToolbar(chat: chat) } .appSheet(isPresented: $showChatInfoSheet) { - GroupChatInfoView(chat: chat, groupInfo: groupInfo) + GroupChatInfoView( + chat: chat, + groupInfo: Binding( + get: { groupInfo }, + set: { gInfo in + chat.chatInfo = .group(groupInfo: gInfo) + chat.created = Date.now + } + ) + ) } } } @@ -208,6 +212,17 @@ struct ChatView: View { } } + private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async { + let groupMembers = await apiListMembers(groupInfo.groupId) + await MainActor.run { + if chatModel.chatId == groupInfo.id { + chatModel.groupMembers = groupMembers.map { GMember.init($0) } + membersLoaded = true + updateView() + } + } + } + private func initChatView() { let cInfo = chat.chatInfo if case let .direct(contact) = cInfo { @@ -416,13 +431,7 @@ struct ChatView: View { private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { - Task { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - showAddMembersSheet = true - } - } + Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { Image(systemName: "person.crop.circle.badge.plus") @@ -477,73 +486,30 @@ struct ChatView: View { } @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { - if case let .groupRcv(member) = ci.chatDir, - case let .group(groupInfo) = chat.chatInfo { - let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci) - if ci.memberConnected != nil && nextItem?.memberConnected != nil { - // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView - ZStack {} // scroll doesn't work if it's EmptyView() - } else { - if prevItem == nil || showMemberImage(member, prevItem) { - VStack(alignment: .leading, spacing: 4) { - if ci.content.showMemberName { - Text(member.displayName) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, memberImageSize + 14) - .padding(.top, 7) - } - HStack(alignment: .top, spacing: 8) { - ProfileImage(imageStr: member.memberProfile.image) - .frame(width: memberImageSize, height: memberImageSize) - .onTapGesture { selectedMember = member } - .appSheet(item: $selectedMember) { member in - GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true) - } - chatItemWithMenu(ci, maxWidth) - } - } - .padding(.top, 5) - .padding(.trailing) - .padding(.leading, 12) - } else { - chatItemWithMenu(ci, maxWidth) - .padding(.top, 5) - .padding(.trailing) - .padding(.leading, memberImageSize + 8 + 12) - } - } - } else { - chatItemWithMenu(ci, maxWidth) - .padding(.horizontal) - .padding(.top, 5) - } - } - - private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { ChatItemWithMenu( - ci: ci, + chat: chat, + chatItem: ci, maxWidth: maxWidth, - scrollProxy: scrollProxy, - deleteMessage: deleteMessage, - deletingItem: $deletingItem, composeState: $composeState, - showDeleteMessage: $showDeleteMessage + selectedMember: $selectedMember, + chatView: self ) - .environmentObject(chat) } private struct ChatItemWithMenu: View { - @EnvironmentObject var chat: Chat + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme - var ci: ChatItem + @ObservedObject var chat: Chat + var chatItem: ChatItem var maxWidth: CGFloat - var scrollProxy: ScrollViewProxy? - var deleteMessage: (CIDeleteMode) -> Void - @Binding var deletingItem: ChatItem? @Binding var composeState: ComposeState - @Binding var showDeleteMessage: Bool + @Binding var selectedMember: GMember? + var chatView: ChatView + @State private var deletingItem: ChatItem? = nil + @State private var showDeleteMessage = false + @State private var deletingItems: [Int64] = [] + @State private var showDeleteMessages = false @State private var revealed = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @@ -555,18 +521,114 @@ struct ChatView: View { @State private var playbackTime: TimeInterval? var body: some View { + let (currIndex, nextItem) = m.getNextChatItem(chatItem) + let ciCategory = chatItem.mergeCategory + if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) { + // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView + ZStack {} // scroll doesn't work if it's EmptyView() + } else { + let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) + let range = itemsRange(currIndex, prevHidden) + if revealed, let range = range { + let items = Array(zip(Array(range), m.reversedChatItems[range])) + ForEach(items, id: \.1.viewId) { (i, ci) in + let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1] + chatItemView(ci, nil, prev) + } + } else { + chatItemView(chatItem, range, prevItem) + } + } + } + + @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?) -> some View { + if case let .groupRcv(member) = ci.chatDir, + case let .group(groupInfo) = chat.chatInfo { + let (prevMember, memCount): (GroupMember?, Int) = + if let range = range { + m.getPrevHiddenMember(member, range) + } else { + (nil, 1) + } + if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { + VStack(alignment: .leading, spacing: 4) { + if ci.content.showMemberName { + Text(memberNames(member, prevMember, memCount)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, memberImageSize + 14) + .padding(.top, 7) + } + HStack(alignment: .top, spacing: 8) { + ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + .onTapGesture { + if chatView.membersLoaded { + selectedMember = m.getGroupMember(member.groupMemberId) + } else { + Task { + await chatView.loadGroupMembers(groupInfo) { + selectedMember = m.getGroupMember(member.groupMemberId) + } + } + } + } + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true) + } + chatItemWithMenu(ci, range, maxWidth) + } + } + .padding(.top, 5) + .padding(.trailing) + .padding(.leading, 12) + } else { + chatItemWithMenu(ci, range, maxWidth) + .padding(.top, 5) + .padding(.trailing) + .padding(.leading, memberImageSize + 8 + 12) + } + } else { + chatItemWithMenu(ci, range, maxWidth) + .padding(.horizontal) + .padding(.top, 5) + } + } + + private func memberNames(_ member: GroupMember, _ prevMember: GroupMember?, _ memCount: Int) -> LocalizedStringKey { + let name = member.displayName + return if let prevName = prevMember?.displayName { + memCount > 2 + ? "\(name), \(prevName) and \(memCount - 2) members" + : "\(name) and \(prevName)" + } else { + "\(name)" + } + } + + @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let uiMenu: Binding = Binding( - get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) }, + get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) }, set: { _ in } ) VStack(alignment: alignment.horizontal, spacing: 3) { - ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) - .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) - .accessibilityLabel("") + ChatItemView( + chat: chat, + chatItem: ci, + maxWidth: maxWidth, + scrollProxy: chatView.scrollProxy, + revealed: $revealed, + allowMenu: $allowMenu, + audioPlayer: $audioPlayer, + playbackState: $playbackState, + playbackTime: $playbackTime + ) + .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) + .accessibilityLabel("") if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { - chatItemReactions() + chatItemReactions(ci) .padding(.bottom, 4) } } @@ -580,6 +642,11 @@ struct ChatView: View { } } } + .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { + Button("Delete for me", role: .destructive) { + deleteMessages() + } + } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .onDisappear { @@ -597,7 +664,15 @@ struct ChatView: View { } } - private func chatItemReactions() -> some View { + private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { + switch (prevItem?.chatDir) { + case .groupSnd: return true + case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId + default: return false + } + } + + private func chatItemReactions(_ ci: ChatItem) -> some View { HStack(spacing: 4) { ForEach(ci.reactions, id: \.reaction) { r in let v = HStack(spacing: 4) { @@ -617,7 +692,7 @@ struct ChatView: View { if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) { v.onTapGesture { - setReaction(add: !r.userReacted, reaction: r.reaction) + setReaction(ci, add: !r.userReacted, reaction: r.reaction) } } else { v @@ -626,10 +701,10 @@ struct ChatView: View { } } - private func menu(live: Bool) -> [UIMenuElement] { + private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> [UIMenuElement] { var menu: [UIMenuElement] = [] if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { - let rs = allReactions() + let rs = allReactions(ci) if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, rs.count > 0 { var rm: UIMenu @@ -646,10 +721,10 @@ struct ChatView: View { menu.append(rm) } if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { - menu.append(replyUIAction()) + menu.append(replyUIAction(ci)) } - menu.append(shareUIAction()) - menu.append(copyUIAction()) + menu.append(shareUIAction(ci)) + menu.append(copyUIAction(ci)) if let fileSource = getLoadedFileSource(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { @@ -662,9 +737,9 @@ struct ChatView: View { } } if ci.meta.editable && !mc.isVoice && !live { - menu.append(editAction()) + menu.append(editAction(ci)) } - menu.append(viewInfoUIAction()) + menu.append(viewInfoUIAction(ci)) if revealed { menu.append(hideUIAction()) } @@ -674,25 +749,31 @@ struct ChatView: View { menu.append(cancelFileUIAction(file.fileId, cancelAction)) } if !live || !ci.meta.isLive { - menu.append(deleteUIAction()) + menu.append(deleteUIAction(ci)) } if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { - menu.append(moderateUIAction(groupInfo)) + menu.append(moderateUIAction(ci, groupInfo)) } } else if ci.meta.itemDeleted != nil { - if !ci.isDeletedContent { + if revealed { + menu.append(hideUIAction()) + } else if !ci.isDeletedContent { menu.append(revealUIAction()) + } else if range != nil { + menu.append(expandUIAction()) } - menu.append(viewInfoUIAction()) - menu.append(deleteUIAction()) + menu.append(viewInfoUIAction(ci)) + menu.append(deleteUIAction(ci)) } else if ci.isDeletedContent { - menu.append(viewInfoUIAction()) - menu.append(deleteUIAction()) + menu.append(viewInfoUIAction(ci)) + menu.append(deleteUIAction(ci)) + } else if ci.mergeCategory != nil { + menu.append(revealed ? shrinkUIAction() : expandUIAction()) } return menu } - private func replyUIAction() -> UIAction { + private func replyUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Reply", comment: "chat item action"), image: UIImage(systemName: "arrowshape.turn.up.left") @@ -727,11 +808,11 @@ struct ChatView: View { ) } - private func allReactions() -> [UIAction] { + private func allReactions(_ ci: ChatItem) -> [UIAction] { MsgReaction.values.compactMap { r in ci.reactions.contains(where: { $0.userReacted && $0.reaction == r }) ? nil - : UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) } + : UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) } } } @@ -739,7 +820,7 @@ struct ChatView: View { rs.count > 4 ? 3 : 4 } - private func setReaction(add: Bool, reaction: MsgReaction) { + private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) { Task { do { let cInfo = chat.chatInfo @@ -751,7 +832,7 @@ struct ChatView: View { reaction: reaction ) await MainActor.run { - ChatModel.shared.updateChatItem(chat.chatInfo, chatItem) + m.updateChatItem(chat.chatInfo, chatItem) } } catch let error { logger.error("apiChatItemReaction error: \(responseError(error))") @@ -759,7 +840,7 @@ struct ChatView: View { } } - private func shareUIAction() -> UIAction { + private func shareUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Share", comment: "chat item action"), image: UIImage(systemName: "square.and.arrow.up") @@ -772,7 +853,7 @@ struct ChatView: View { } } - private func copyUIAction() -> UIAction { + private func copyUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Copy", comment: "chat item action"), image: UIImage(systemName: "doc.on.doc") @@ -805,7 +886,7 @@ struct ChatView: View { } } - private func editAction() -> UIAction { + private func editAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Edit", comment: "chat item action"), image: UIImage(systemName: "square.and.pencil") @@ -816,7 +897,7 @@ struct ChatView: View { } } - private func viewInfoUIAction() -> UIAction { + private func viewInfoUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Info", comment: "chat item action"), image: UIImage(systemName: "info.circle") @@ -829,10 +910,7 @@ struct ChatView: View { chatItemInfo = ciInfo } if case let .group(gInfo) = chat.chatInfo { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - } + await chatView.loadGroupMembers(gInfo) } } catch let error { logger.error("apiGetChatItemInfo error: \(responseError(error))") @@ -853,7 +931,7 @@ struct ChatView: View { message: Text(cancelAction.alert.message), primaryButton: .destructive(Text(cancelAction.alert.confirm)) { Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await cancelFile(user: user, fileId: fileId) } } @@ -874,18 +952,45 @@ struct ChatView: View { } } - private func deleteUIAction() -> UIAction { + private func deleteUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Delete", comment: "chat item action"), image: UIImage(systemName: "trash"), attributes: [.destructive] ) { _ in - showDeleteMessage = true - deletingItem = ci + if !revealed && ci.meta.itemDeleted != nil, + let currIndex = m.getChatItemIndex(ci), + let ciCategory = ci.mergeCategory { + let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) + if let range = itemsRange(currIndex, prevHidden) { + var itemIds: [Int64] = [] + for i in range { + itemIds.append(m.reversedChatItems[i].id) + } + showDeleteMessages = true + deletingItems = itemIds + } else { + showDeleteMessage = true + deletingItem = ci + } + } else { + showDeleteMessage = true + deletingItem = ci + } } } - private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction { + private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange? { + if let currIndex = currIndex, + let prevHidden = prevHidden, + prevHidden > currIndex { + currIndex...prevHidden + } else { + nil + } + } + + private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction { UIAction( title: NSLocalizedString("Moderate", comment: "chat item action"), image: UIImage(systemName: "flag"), @@ -917,20 +1022,105 @@ struct ChatView: View { } } } - + + private func expandUIAction() -> UIAction { + UIAction( + title: NSLocalizedString("Expand", comment: "chat item action"), + image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down") + ) { _ in + withAnimation { + revealed = true + } + } + } + + private func shrinkUIAction() -> UIAction { + UIAction( + title: NSLocalizedString("Hide", comment: "chat item action"), + image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up") + ) { _ in + withAnimation { + revealed = false + } + } + } + private var broadcastDeleteButtonText: LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } - } - private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { - switch (prevItem?.chatDir) { - case .groupSnd: return true - case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId - default: return false + var deleteMessagesTitle: LocalizedStringKey { + let n = deletingItems.count + return n == 1 ? "Delete message?" : "Delete \(n) messages?" + } + + private func deleteMessages() { + let itemIds = deletingItems + if itemIds.count > 0 { + let chatInfo = chat.chatInfo + Task { + var deletedItems: [ChatItem] = [] + for itemId in itemIds { + do { + let (di, _) = try await apiDeleteChatItem( + type: chatInfo.chatType, + id: chatInfo.apiId, + itemId: itemId, + mode: .cidmInternal + ) + deletedItems.append(di) + } catch { + logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + } + } + await MainActor.run { + for di in deletedItems { + m.removeChatItem(chatInfo, di) + } + } + } + } + } + + private func deleteMessage(_ mode: CIDeleteMode) { + logger.debug("ChatView deleteMessage") + Task { + logger.debug("ChatView deleteMessage: in Task") + do { + if let di = deletingItem { + var deletedItem: ChatItem + var toItem: ChatItem? + if case .cidmBroadcast = mode, + let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { + (deletedItem, toItem) = try await apiDeleteMemberChatItem( + groupId: groupInfo.apiId, + groupMemberId: groupMember.groupMemberId, + itemId: di.id + ) + } else { + (deletedItem, toItem) = try await apiDeleteChatItem( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: di.id, + mode: mode + ) + } + DispatchQueue.main.async { + deletingItem = nil + if let toItem = toItem { + _ = m.upsertChatItem(chat.chatInfo, toItem) + } else { + m.removeChatItem(chat.chatInfo, deletedItem) + } + } + } + } catch { + logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + } + } } } - + private func scrollToBottom(_ proxy: ScrollViewProxy) { if let ci = chatModel.reversedChatItems.first { withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } @@ -942,44 +1132,6 @@ struct ChatView: View { withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } } } - - private func deleteMessage(_ mode: CIDeleteMode) { - logger.debug("ChatView deleteMessage") - Task { - logger.debug("ChatView deleteMessage: in Task") - do { - if let di = deletingItem { - var deletedItem: ChatItem - var toItem: ChatItem? - if case .cidmBroadcast = mode, - let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { - (deletedItem, toItem) = try await apiDeleteMemberChatItem( - groupId: groupInfo.apiId, - groupMemberId: groupMember.groupMemberId, - itemId: di.id - ) - } else { - (deletedItem, toItem) = try await apiDeleteChatItem( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - itemId: di.id, - mode: mode - ) - } - DispatchQueue.main.async { - deletingItem = nil - if let toItem = toItem { - _ = chatModel.upsertChatItem(chat.chatInfo, toItem) - } else { - chatModel.removeChatItem(chat.chatInfo, deletedItem) - } - } - } - } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") - } - } - } } @ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3328da8dbe..0572821770 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -592,12 +592,14 @@ struct ComposeView: View { EmptyView() case let .quotedItem(chatItem: quotedItem): ContextItemView( + chat: chat, contextItem: quotedItem, contextIcon: "arrowshape.turn.up.left", cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) case let .editingItem(chatItem: editingItem): ContextItemView( + chat: chat, contextItem: editingItem, contextIcon: "pencil", cancelContextItem: { clearState() } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 153d89fc27..868ae3274a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct ContextItemView: View { @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat let contextItem: ChatItem let contextIcon: String let cancelContextItem: () -> Void @@ -48,6 +49,7 @@ struct ContextItemView: View { private func msgContentView(lines: Int) -> some View { MsgContentView( + chat: chat, text: contextItem.text, formattedText: contextItem.formattedText ) @@ -59,6 +61,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) } } diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 123d93714d..d206b9b418 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View { do { for contactId in selectedContacts { let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole) - await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) } + await MainActor.run { _ = chatModel.upsertGroupMember(groupInfo, member) } } addedMembersCb(selectedContacts) } catch { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index dd2392b6dc..94a018749e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -15,7 +15,7 @@ struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat - @State var groupInfo: GroupInfo + @Binding var groupInfo: GroupInfo @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @@ -35,14 +35,30 @@ struct GroupChatInfoView: View { case leaveGroupAlert case cantInviteIncognitoAlert case largeGroupReceiptsDisabled + case blockMemberAlert(mem: GroupMember) + case unblockMemberAlert(mem: GroupMember) + case removeMemberAlert(mem: GroupMember) + case error(title: LocalizedStringKey, error: LocalizedStringKey) - var id: GroupChatInfoViewAlert { get { self } } + var id: String { + switch self { + case .deleteGroupAlert: return "deleteGroupAlert" + case .clearChatAlert: return "clearChatAlert" + case .leaveGroupAlert: return "leaveGroupAlert" + case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert" + case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled" + case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" + case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" + case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" + case let .error(title, _): return "error \(title)" + } + } } var body: some View { NavigationView { let members = chatModel.groupMembers - .filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved } + .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } List { @@ -57,7 +73,7 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } groupPreferencesButton($groupInfo) - if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { sendReceiptsOption() } else { sendReceiptsOptionDisabled() @@ -84,17 +100,17 @@ struct GroupChatInfoView: View { .padding(.leading, 8) } let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) } - memberView(groupInfo.membership, user: true) + let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) ForEach(filteredMembers) { member in ZStack { NavigationLink { - memberInfoView(member.groupMemberId) + memberInfoView(member) } label: { EmptyView() } .opacity(0) - memberView(member) + MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) } } } @@ -126,6 +142,10 @@ struct GroupChatInfoView: View { case .leaveGroupAlert: return leaveGroupAlert() case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert() case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() + case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) + case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) + case let .removeMemberAlert(mem): return removeMemberAlert(mem) + case let .error(title, error): return Alert(title: Text(title), message: Text(error)) } } .onAppear { @@ -174,7 +194,7 @@ struct GroupChatInfoView: View { Task { let groupMembers = await apiListMembers(groupInfo.groupId) await MainActor.run { - ChatModel.shared.groupMembers = groupMembers + chatModel.groupMembers = groupMembers.map { GMember.init($0) } } } } @@ -183,44 +203,79 @@ struct GroupChatInfoView: View { } } - private func memberView(_ member: GroupMember, user: Bool = false) -> some View { - HStack{ - ProfileImage(imageStr: member.image) - .frame(width: 38, height: 38) - .padding(.trailing, 2) - // TODO server connection status - VStack(alignment: .leading) { - let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) - (member.verified ? memberVerifiedShield + t : t) - .lineLimit(1) - let s = Text(member.memberStatus.shortText) - (user ? Text ("you: ") + s : s) - .lineLimit(1) - .font(.caption) - .foregroundColor(.secondary) + private struct MemberRowView: View { + var groupInfo: GroupInfo + @ObservedObject var groupMember: GMember + var user: Bool = false + @Binding var alert: GroupChatInfoViewAlert? + + var body: some View { + let member = groupMember.wrapped + let v = HStack{ + ProfileImage(imageStr: member.image) + .frame(width: 38, height: 38) + .padding(.trailing, 2) + // TODO server connection status + VStack(alignment: .leading) { + let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) + (member.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + let s = Text(member.memberStatus.shortText) + (user ? Text ("you: ") + s : s) + .lineLimit(1) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + let role = member.memberRole + if role == .owner || role == .admin { + Text(member.memberRole.text) + .foregroundColor(.secondary) + } } - Spacer() - let role = member.memberRole - if role == .owner || role == .admin { - Text(member.memberRole.text) - .foregroundColor(.secondary) + + if user { + v + } else if member.canBeRemoved(groupInfo: groupInfo) { + removeSwipe(member, blockSwipe(member, v)) + } else { + blockSwipe(member, v) + } + } + + private func blockSwipe(_ member: GroupMember, _ v: V) -> some View { + v.swipeActions(edge: .leading) { + if member.memberSettings.showMessages { + Button { + alert = .blockMemberAlert(mem: member) + } label: { + Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary) + } + } else { + Button { + alert = .unblockMemberAlert(mem: member) + } label: { + Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor) + } + } + } + } + + private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { + v.swipeActions(edge: .trailing) { + Button(role: .destructive) { + alert = .removeMemberAlert(mem: member) + } label: { + Label("Remove member", systemImage: "trash") + .foregroundColor(Color.red) + } } } } - private var memberVerifiedShield: Text { - (Text(Image(systemName: "checkmark.shield")) + Text(" ")) - .font(.caption) - .baselineOffset(2) - .kerning(-2) - .foregroundColor(.secondary) - } - - @ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View { - if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) { - GroupMemberInfoView(groupInfo: groupInfo, member: member) - .navigationBarHidden(false) - } + private func memberInfoView(_ groupMember: GMember) -> some View { + GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember) + .navigationBarHidden(false) } private func groupLinkButton() -> some View { @@ -381,6 +436,28 @@ struct GroupChatInfoView: View { alert = .largeGroupReceiptsDisabled } } + + private func removeMemberAlert(_ mem: GroupMember) -> Alert { + Alert( + title: Text("Remove member?"), + message: Text("Member will be removed from group - this cannot be undone!"), + primaryButton: .destructive(Text("Remove")) { + Task { + do { + let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + await MainActor.run { + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } + } catch let error { + logger.error("apiRemoveMember error: \(responseError(error))") + let a = getErrorAlert(error, "Error removing member") + alert = .error(title: a.title, error: a.message) + } + } + }, + secondaryButton: .cancel() + ) + } } func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bool = false) -> some View { @@ -402,6 +479,14 @@ func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bo } } +private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(.secondary) +} + func cantInviteIncognitoAlert() -> Alert { Alert( title: Text("Can't invite contacts!"), @@ -418,6 +503,9 @@ func largeGroupReceiptsDisabledAlert() -> Alert { struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { - GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData) + GroupChatInfoView( + chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), + groupInfo: Binding.constant(GroupInfo.sampleData) + ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 4b64458145..4a187cecb9 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -12,8 +12,8 @@ import SimpleXChat struct GroupMemberInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction - var groupInfo: GroupInfo - @State var member: GroupMember + @State var groupInfo: GroupInfo + @ObservedObject var groupMember: GMember var navigation: Bool = false @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @@ -25,6 +25,8 @@ struct GroupMemberInfoView: View { @State private var progressIndicator = false enum GroupMemberInfoViewAlert: Identifiable { + case blockMemberAlert(mem: GroupMember) + case unblockMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case switchAddressAlert @@ -35,8 +37,10 @@ struct GroupMemberInfoView: View { var id: String { switch self { - case .removeMemberAlert: return "removeMemberAlert" - case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)" + case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" + case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" + case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" + case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" @@ -66,6 +70,7 @@ struct GroupMemberInfoView: View { private func groupMemberInfoView() -> some View { ZStack { VStack { + let member = groupMember.wrapped List { groupMemberInfoHeader(member) .listRowBackground(Color.clear) @@ -159,9 +164,14 @@ struct GroupMemberInfoView: View { } } - if member.canBeRemoved(groupInfo: groupInfo) { - Section { - removeMemberButton(member) + Section { + if member.memberSettings.showMessages { + blockMemberButton(member) + } else { + unblockMemberButton(member) + } + if member.canBeRemoved(groupInfo: groupInfo) { + removeMemberButton(member) } } @@ -182,7 +192,7 @@ struct GroupMemberInfoView: View { do { let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) - member = mem + _ = chatModel.upsertGroupMember(groupInfo, mem) connectionStats = stats connectionCode = code } catch let error { @@ -190,15 +200,20 @@ struct GroupMemberInfoView: View { } justOpened = false } - .onChange(of: newRole) { _ in + .onChange(of: newRole) { newRole in if newRole != member.memberRole { alert = .changeMemberRoleAlert(mem: member, role: newRole) } } + .onChange(of: member.memberRole) { role in + newRole = role + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in switch(alertItem) { + case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) + case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) @@ -263,7 +278,7 @@ struct GroupMemberInfoView: View { progressIndicator = true Task { do { - let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId) + let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) await MainActor.run { progressIndicator = false chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) @@ -321,20 +336,20 @@ struct GroupMemberInfoView: View { } private func verifyCodeButton(_ code: String) -> some View { - NavigationLink { + let member = groupMember.wrapped + return NavigationLink { VerifyCodeView( displayName: member.displayName, connectionCode: code, connectionVerified: member.verified, verify: { code in + var member = groupMember.wrapped if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) { let (verified, existingCode) = r let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil connectionCode = existingCode member.activeConn?.connectionCode = connCode - if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { - chatModel.groupMembers[i].activeConn?.connectionCode = connCode - } + _ = chatModel.upsertGroupMember(groupInfo, member) return r } return nil @@ -368,12 +383,29 @@ struct GroupMemberInfoView: View { } } + private func blockMemberButton(_ mem: GroupMember) -> some View { + Button(role: .destructive) { + alert = .blockMemberAlert(mem: mem) + } label: { + Label("Block member", systemImage: "hand.raised") + .foregroundColor(.red) + } + } + + private func unblockMemberButton(_ mem: GroupMember) -> some View { + Button { + alert = .unblockMemberAlert(mem: mem) + } label: { + Label("Unblock member", systemImage: "hand.raised.slash") + } + } + private func removeMemberButton(_ mem: GroupMember) -> some View { Button(role: .destructive) { alert = .removeMemberAlert(mem: mem) } label: { Label("Remove member", systemImage: "trash") - .foregroundColor(Color.red) + .foregroundColor(.red) } } @@ -409,7 +441,6 @@ struct GroupMemberInfoView: View { do { let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) await MainActor.run { - member = updatedMember _ = chatModel.upsertGroupMember(groupInfo, updatedMember) } @@ -430,10 +461,10 @@ struct GroupMemberInfoView: View { private func switchMemberAddress() { Task { do { - let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) connectionStats = stats await MainActor.run { - chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) dismiss() } } catch let error { @@ -449,10 +480,10 @@ struct GroupMemberInfoView: View { private func abortSwitchMemberAddress() { Task { do { - let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) connectionStats = stats await MainActor.run { - chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) } } catch let error { logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))") @@ -467,7 +498,7 @@ struct GroupMemberInfoView: View { private func syncMemberConnection(force: Bool) { Task { do { - let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force) + let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force) connectionStats = stats await MainActor.run { chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats) @@ -484,11 +515,54 @@ struct GroupMemberInfoView: View { } } +func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { + Alert( + title: Text("Block member?"), + message: Text("All new messages from \(mem.chatViewName) will be hidden!"), + primaryButton: .destructive(Text("Block")) { + toggleShowMemberMessages(gInfo, mem, false) + }, + secondaryButton: .cancel() + ) +} + +func unblockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { + Alert( + title: Text("Unblock member?"), + message: Text("Messages from \(mem.chatViewName) will be shown!"), + primaryButton: .default(Text("Unblock")) { + toggleShowMemberMessages(gInfo, mem, true) + }, + secondaryButton: .cancel() + ) +} + +func toggleShowMemberMessages(_ gInfo: GroupInfo, _ member: GroupMember, _ showMessages: Bool) { + var memberSettings = member.memberSettings + memberSettings.showMessages = showMessages + updateMemberSettings(gInfo, member, memberSettings) +} + +func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSettings: GroupMemberSettings) { + Task { + do { + try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + await MainActor.run { + var mem = member + mem.memberSettings = memberSettings + _ = ChatModel.shared.upsertGroupMember(gInfo, mem) + } + } catch let error { + logger.error("apiSetMemberSettings error \(responseError(error))") + } + } +} + struct GroupMemberInfoView_Previews: PreviewProvider { static var previews: some View { GroupMemberInfoView( groupInfo: GroupInfo.sampleData, - member: GroupMember.sampleData + groupMember: GMember.sampleData ) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index eb0a5cba68..baab2bcf95 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -29,7 +29,7 @@ struct ChatListView: View { ZStack(alignment: .topLeading) { NavStackCompat( isActive: Binding( - get: { ChatModel.shared.chatId != nil }, + get: { chatModel.chatId != nil }, set: { _ in } ), destination: chatView diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 7c973c73c6..6d2fba99c6 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -119,7 +119,7 @@ struct ContactConnectionInfo: View { if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) { await MainActor.run { contactConnection = conn - ChatModel.shared.updateContactConnection(conn) + m.updateContactConnection(conn) dismiss() } } diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 69393cabb8..5b982f5f0a 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -52,7 +52,6 @@ struct VideoPlayerView: UIViewRepresentable { var timeObserver: Any? = nil deinit { - print("deinit coordinator of VideoPlayer") if let timeObserver = timeObserver { NotificationCenter.default.removeObserver(timeObserver) } diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 344a8d1f96..de8e35d2a6 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -48,7 +48,7 @@ struct AddContactView: View { let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { await MainActor.run { contactConnection = conn - ChatModel.shared.updateContactConnection(conn) + chatModel.updateContactConnection(conn) } } } catch { diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 22bf1c4096..2d7f31c58e 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -189,7 +189,7 @@ struct AddGroupView: View { Task { let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { - ChatModel.shared.groupMembers = groupMembers + m.groupMembers = groupMembers.map { GMember.init($0) } } } let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 51bfb96940..90b83fa4f3 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -119,7 +119,7 @@ struct PrivacySettings: View { Text("Send delivery receipts to") } footer: { VStack(alignment: .leading) { - Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") + Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") Text("They can be overridden in contact and group settings.") } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4afe2583ca..8c15d94537 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -73,6 +73,7 @@ public enum ChatCommand { case apiGetNetworkConfig case reconnectAllServers case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) + case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) case apiContactInfo(contactId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiSwitchContact(contactId: Int64) @@ -198,6 +199,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "/network" case .reconnectAllServers: return "/reconnect" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" + case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" @@ -325,6 +327,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .reconnectAllServers: return "reconnectAllServers" case .apiSetChatSettings: return "apiSetChatSettings" + case .apiSetMemberSettings: return "apiSetMemberSettings" case .apiContactInfo: return "apiContactInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiSwitchContact: return "apiSwitchContact" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c31786c700..25511e1bae 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -422,8 +422,8 @@ public enum CustomTimeUnit { public func timeText(_ seconds: Int?) -> String { - guard let seconds = seconds else { return "off" } - if seconds == 0 { return "0 sec" } + guard let seconds = seconds else { return NSLocalizedString("off", comment: "time to disappear") } + if seconds == 0 { return NSLocalizedString("0 sec", comment: "time to disappear") } return CustomTimeUnit.toText(seconds: seconds) } @@ -1867,8 +1867,8 @@ public struct GroupMember: Identifiable, Decodable { ) } -public struct GroupMemberSettings: Decodable { - var showMessages: Bool +public struct GroupMemberSettings: Codable { + public var showMessages: Bool } public struct GroupMemberRef: Decodable { @@ -2090,7 +2090,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberConnected: GroupMember? { switch chatDir { - case .groupRcv(let groupMember): + case let .groupRcv(groupMember): switch content { case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember default: return nil @@ -2099,6 +2099,35 @@ public struct ChatItem: Identifiable, Decodable { } } + public var mergeCategory: CIMergeCategory? { + switch content { + case .rcvChatFeature: .chatFeature + case .sndChatFeature: .chatFeature + case .rcvGroupFeature: .chatFeature + case .sndGroupFeature: .chatFeature + case let.rcvGroupEvent(event): + switch event { + case .userRole: nil + case .userDeleted: nil + case .groupDeleted: nil + case .memberCreatedContact: nil + default: .rcvGroupEvent + } + case let .sndGroupEvent(event): + switch event { + case .userRole: nil + case .userLeft: nil + default: .sndGroupEvent + } + default: + if meta.itemDeleted == nil { + nil + } else { + chatDir.sent ? .sndItemDeleted : .rcvItemDeleted + } + } + } + private var showNtfDir: Bool { return !chatDir.sent } @@ -2176,7 +2205,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { - return groupMember.displayName + return groupMember.chatViewName } else { return nil } @@ -2330,6 +2359,15 @@ public struct ChatItem: Identifiable, Decodable { } } +public enum CIMergeCategory { + case memberConnected + case rcvGroupEvent + case sndGroupEvent + case sndItemDeleted + case rcvItemDeleted + case chatFeature +} + public enum CIDirection: Decodable { case directSnd case directRcv @@ -2508,11 +2546,13 @@ public enum SndCIStatusProgress: String, Decodable { public enum CIDeleted: Decodable { case deleted(deletedTs: Date?) + case blocked(deletedTs: Date?) case moderated(deletedTs: Date?, byGroupMember: GroupMember) var id: String { switch self { case .deleted: return "deleted" + case .blocked: return "blocked" case .moderated: return "moderated" } } @@ -2530,8 +2570,8 @@ protocol ItemContent { public enum CIContent: Decodable, ItemContent { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) - case sndDeleted(deleteMode: CIDeleteMode) - case rcvDeleted(deleteMode: CIDeleteMode) + case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used + case rcvDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used case sndCall(status: CICallStatus, duration: Int) case rcvCall(status: CICallStatus, duration: Int) case rcvIntegrityError(msgError: MsgErrorType) From c8c17a2f68ebc87ae5554be1d1ecf2f610f1f176 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 1 Nov 2023 13:10:19 +0000 Subject: [PATCH 75/80] core: fix uri parse to not include trailing punctuation in URIs (#3296) * core: fix uri parse to not include trailing punctuation in URIs * simplify --- src/Simplex/Chat/Markdown.hs | 12 ++++++++---- tests/MarkdownTests.hs | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 30990f225d..969c7c2b56 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -14,7 +14,7 @@ import Data.Aeson (ToJSON) import qualified Data.Aeson as J import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A -import Data.Char (isDigit) +import Data.Char (isDigit, isPunctuation) import Data.Either (fromRight) import Data.Functor (($>)) import Data.List (intercalate, foldl') @@ -217,11 +217,15 @@ markdownP = mconcat <$> A.many' fragmentP wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s - | isUri s = case strDecode $ encodeUtf8 s of - Right cReq -> markdown (simplexUriFormat cReq) s - _ -> markdown Uri s + | isUri s = + let t = T.takeWhileEnd isPunctuation s + uri = uriMarkdown $ T.dropWhileEnd isPunctuation s + in if T.null t then uri else uri :|: unmarked t | isEmail s = markdown Email s | otherwise = unmarked s + uriMarkdown s = case strDecode $ encodeUtf8 s of + Right cReq -> markdown (simplexUriFormat cReq) s + _ -> markdown Uri s isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 83a180c745..1cd2aa2c47 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -144,6 +144,8 @@ textWithUri :: Spec textWithUri = describe "text with Uri" do it "correct markdown" do parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat" + parseMarkdown "https://simplex.chat." `shouldBe` uri "https://simplex.chat" <> "." + parseMarkdown "https://simplex.chat, hello" `shouldBe` uri "https://simplex.chat" <> ", hello" parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat" parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat" parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site" From c1a0486c1dbd518b01fcb4ce3be6debcc92d2fbe Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 1 Nov 2023 17:30:19 +0400 Subject: [PATCH 76/80] docs: groups integrity rfc (#3128) --- docs/rfcs/2023-09-25-groups-integrity.md | 124 +++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/rfcs/2023-09-25-groups-integrity.md diff --git a/docs/rfcs/2023-09-25-groups-integrity.md b/docs/rfcs/2023-09-25-groups-integrity.md new file mode 100644 index 0000000000..c7f0d99f93 --- /dev/null +++ b/docs/rfcs/2023-09-25-groups-integrity.md @@ -0,0 +1,124 @@ +# Groups integrity + +## Problems + +- Inconsistency of group state: + - group profile including group wide preferences, + - list of members and their roles. +- Lack of group messages integrity - group member can send different messages to different members. + +Lack of group consistency leads to group federation both in terms of members list and content visible to different members, which leads to user frustration and lack of trust. + +Improvements to group design should provide: + +- Consistent group state. +- Group messages integrity: + - integrity violations (different message sent to different members) should be identified and shown to users, + - missed messages should be requested to fill in gaps. + +## Design ideas and questions + +### Group messages integrity + +A message container to include member's message ID (ordered?), and list of IDs and hashes of parent messages. + +```haskell +data MsgParentId = MsgParentId + { memberId :: MemberId, + msgId :: Int64, -- sequential message ID for parent message (among memberId member messages) + msgHash :: ByteString + } + +data MsgIds = MsgIds + { msgId :: Int64, -- sequential message ID for member's message + parentIds :: [MsgParentId] + } +``` + +Questions: + - What level of protocol should include MsgIds, and what messages should be included into integrity graph? + - Having it on AppMessage level would allow to include all protocol messages. But some protocol messages are sent with different content per member (XGrpMemIntro, XGrpMemFwd, probe messages) and would have different hash. Also they contain sensitive data such as invitation links and should not be forwarded anyway. + - If MsgIds is MsgContainer level, only XMsgNew would have it. This excludes other content messages such as updates, deletes, etc. + - Include it into specific "content" chat events - XMsgNew, XMsgFileCancel (unused), XMsgUpdate, XMsgDel, XMsgReact, XFile (not used anymore but was never fully deprecated), XFileCancel. + - Some new protocol level container, uniting above events? + - Should msgId be sequential integer? (It leaks metadata about member's previous activity in the group) Can SharedMsgId be used instead? + - Depending on number of parent messages, parentIds can become arbitrarily long and not fit into 16KB block, especially for messages containing profiles pictures. + +When receiving a message with unknown parent identifiers, client should request missing messages from the sender by sending XGrpRequestSkipped, including last seen message reference for each missing parent. When receiving XGrpRequestSkipped, member should forward requested messages up to last seen parent using XGrpRequested. + +```haskell +-- include received parentId? +XGrpRequestSkipped :: [MsgParentId] -> ChatMsgEvent 'Json + +data MsgRequestedParent = MsgRequested + { parentId :: MsgParentId, + msg :: MsgContainer -- content TBD based on scope of messages included into integrity graph. Full event? + } + +XGrpRequested :: MsgRequestedParent -> ChatMsgEvent 'Json +``` + +Questions: + - Depending on number of missing parents, XGrpRequestSkipped may not fit into 16KB block. + - There may be multiple skipped messages for a given member, should they be sent sequentially from oldest (following the one known to requesting member) to newest? + - XGrpRequested may not fit into 16KB block even if original MsgContainer / chat event did fit. On the other hand multiple XGrpRequested messages can be batched. + - Malicious group member may arbitrarily request (at any time or in response to a new message) any number of skipped messages by sending parentIds from the past and trigger receiving member to send a lot of traffic. There are already some automatic response events in protocol, but they are harder to abuse: XGrpMemFwd - requires cooperation with other member, or creating connection; receipts - can be turned off; probes - requires member having matching contact and being non incognito in group. Should the member receiving XGrpRequestSkipped protect from such abuse by limiting number of requested messages? Limiting number or requests from a specific member in time? + - By the time member requests skipped messages, sender may be offline. Should the requester send XGrpRequestSkipped to other members? + - together with the request to sender or after some period? + - to which members? - fraction of admins? all admins? + - Member receiving XGrpRequestSkipped may not have requested messages, for example: + - request is for the older parent id, and member never received it himself (was not part of the group then or has gap in place of this message), or has gap between sent message parent and requested parent. + - member deleted parent(s), e.g. via periodic cleanup, or by deleting specific messages. + - don't fully delete group message records while in group? instead only overwrite content? + +Message integrity is computed for received messages, can be updated on receiving requested message parents. + +```haskell +data GroupMsgIntegrity + = GMIOk + | GMISkippedParents {skippedParents :: [MsgParentId]} + | GMIBadParentHash {knownParent :: MsgParentId, badParent :: MsgParentId} -- list? +``` + +```sql +CREATE TABLE message_integrity_records( -- message_hashes? group_messages? + message_integrity_record_id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- SET NULL? + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + member_id BLOB NOT NULL, + member_msg_id INTEGER NOT NULL, -- shared_msg_id? + msg_hash BLOB NOT NULL, + msg_integrity TEXT NOT NULL, -- computed for received messages, for sent always Ok? + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +-- many to many table for message_integrity_records table +-- (parent can have multiple children, child can have multiple parents) +-- parent can be null if it wasn't received +CREATE TABLE message_parents( + message_parent_id INTEGER PRIMARY KEY, + message_integrity_record_id INTEGER NOT NULL REFERENCES message_integrity_record_id ON DELETE CASCADE, + message_parent_integrity_record_id INTEGER REFERENCES message_integrity_record_id ON DELETE CASCADE, + msg_parent_member_id BLOB NOT NULL, + msg_parent_member_msg_id INTEGER NOT NULL, + msg_hash BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +``` + +How should message integrity errors be displayed in UI? + - Displaying skipped parent errors would clutter UI due to delays in delivery. Probably they shouldn't be displayed. + - Integrity violations (hashes not matching) should be displayed on respective chat items. + - if integrity is on AppMessage level for all chat events - not all messages have corresponding chat items, create internal chat items? + - if it's on the level of content messages, updates / etc. can be high above in message history, deletes can be not visible at all (full delete). + - how to get reference to message via chat item when loading chat items? Integrity violation can be on a message different than chat item's created_by_msg_id message. For each chat item load integrity of all messages via chat_item_messages? + - If integrity errors are only displayed on integrity violations, for malicious member to work around it and send different message to different group members could he specify unknown (far into future or past) message id, instead of incorrect one? Sender then wouldn't respond with skipped parents (and other members wouldn't be able to) - how to differentiate between this case and skipper parent error that is to be ignored in UI? + - Should it be prohibited to not send MsgIds (to avoid message integrity check) if member protocol version supports it? Should it be prohibited at all and group with integrity be separated? How to distinguish between messages sent without integrity fields and messages with skipped parents in UI? + - Not showing skipped parents integrity error in UI would lead user to believe integrity is preserved, and integrity violation can be revealed later. If conversation is time sensitive member may react to message considering it conversation integrity wasn't breached, and integrity violation may be revealed later. Having eventual integrity may not be better than having no integrity at all, and may even be worse because it produces false assumptions regarding conversation integrity. The goal can be narrowed to only restoring missed messages (gaps), without calculating integrity. + +### Consistent group state + +TODO From 68873464d7f899f14bee616aea404cc42c1b1382 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 1 Nov 2023 17:30:40 +0400 Subject: [PATCH 77/80] docs: groups integrity DAGs rfc (#3258) --- docs/rfcs/2023-10-20-group-integrity.md | 229 ++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/rfcs/2023-10-20-group-integrity.md diff --git a/docs/rfcs/2023-10-20-group-integrity.md b/docs/rfcs/2023-10-20-group-integrity.md new file mode 100644 index 0000000000..0a0e7ef295 --- /dev/null +++ b/docs/rfcs/2023-10-20-group-integrity.md @@ -0,0 +1,229 @@ +# Group integrity + +3 level of DAGs: + +Owner + - group profile and permissions, admin invites and removals + - in case of gap vote before applying event + +Admin + - member invites and removals + - prohibit to add and remove admins + - in case of gap most destructive wins + - link to owner dag + +Messages + - in case of gap show history according to local graph, correct when owner or admin dag changes + - link to both admin and owner dags + +```haskell +-- protocol +data MsgParent = MsgParent + { memberId :: MemberId, + memberName :: String, -- recipient can use to display message if they don't have member introduced; + -- optional? + sharedMsgId :: SharedMsgId, + msgHash :: ByteString, + msgBody :: String? -- recipient can use to display message in case parent wasn't yet received; + -- sender can pack as many parents as fits into block + stored :: Bool -- whether sender has message stored, and it can be requested + } + +data MsgIds = MsgIds -- include into chat event + { sharedMsgId :: SharedMsgId, + ownerDAGMsgId :: SharedMsgId, -- list of parents? + adminDAGMsgId :: SharedMsgId, + parents :: [MsgParent] + } + +-- model +data OwnerDAGEventParent + = ODEPKnown {eventId :: ?} -- DB id? sharedMsgId? + | ODEPUnknown {eventId :: ?} + +data OwnerDAGEvent = DAGEvent + { eventId :: ?, + parents :: [OwnerDAGEventParent] + } + +data AdminDAGEventParent + = ADEPKnown {eventId :: ?} + | ADEPUnknown {eventId :: ?} + +data AdminDAGEvent = DAGEvent + { eventId :: ?, + ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ? + parents :: [AdminDAGEventParent] + } + +data MessagesDAGEventParent + = MDEPKnown {eventId :: ?} + | MDEPUnknown {eventId :: ?} + +data MessagesDAGEvent = DAGEvent + { eventId :: ?, + ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ? + adminDAGEventId :: ?, -- [AdminDAGEventParent] - parentIds? ? + parents :: [MessagesDAGEventParent] + } +``` + +How to restore from destructive messages? +Even if all message parents are known, destructive logic of message should be applied after other members refer it. + +How to workaround members maliciously referring non-existent parents? +For example, this can lead to an owner preventing group updates. + +``` +-- should dag be maintained in memory? older events to be removed +-- read on event? +-- how long into past to get dag? + +ClassifiedEvent = OwnerEvent | AdminEvent | MsgEvent + +def processEvent(e: Event) = + classifiedEvent <- classifyEvent(e) + case classifiedEvent of + OwnerEvent oe -> processOwnerEvent(oe) + AdminEvent ae -> processAdminEvent(ae) + MsgEvent me -> processMsgEvent(me) + +def classifyEvent(e: Event) -> ClassifiedEvent? = + case e of + XMsgNew -> MsgEvent + XMsgFileDescr -> Nothing -- different per member + XMsgFileCancel -> MsgEvent + XMsgUpdate -> MsgEvent + XMsgDel -> MsgEvent + XMsgReact -> MsgEvent + XFile -> MsgEvent + XFileCancel -> MsgEvent + XFileAcptInv -> Nothing -- different per member + XGrpMemNew -> OwnerEvent -- sent by owner, new member is admin or owner + or AdminEvent -- sent by admin (or by owner and new member role is less than admin?) + -- problem: if member role changes, members can add event to different dags + -- what should define member role? + XGrpMemIntro -> Nothing -- received only by invitee + XGrpMemInv -> Nothing -- received only by host + XGrpMemFwd -> Nothing -- different per member; not received by invitee + XGrpMemRole -> OwnerEvent -- sent by owner about owner or admin + or AdminEvent -- sent by admin (or by owner about member with role less than admin?) + XGrpMemDel -> OwnerEvent -- sent by owner about owner or admin + or AdminEvent -- sent by admin (or by owner about member with role less than admin?) + XGrpLeave -> MsgEvent + XGrpDel -> OwnerEvent + XGrpInfo -> OwnerEvent + XGrpDirectInv -> Nothing -- received by single member + XInfoProbe -> Nothing -- per member + XInfoProbeCheck -> Nothing -- per member + XInfoProbeOk -> Nothing -- per member + BFileChunk -> Nothing -- could be MsgEvent? + _ -> Nothing -- not supported in groups + +-- # owner events + +def processOwnerEvent(oe: OwnerEvent) = + process every owner event after owners reach consensus + +// def processOwnerEvent(oe: OwnerEvent) = +// addOwnerDagEvent(oe) +// applyOwnerDagEvent(oe) +// +// def addOwnerDagEvent(oe: OwnerEvent) = +// if (any parent of oe not in dag): +// buffer until all parents are in ownerDag +// else +// add oe to ownerDag +// +// def applyOwnerDagEvent(oe: OwnerEvent) = +// case oe of +// -- process XGrpMemNew, XGrpMemRole, XGrpMemDel same as for admin dag (see below), or should vote for all events? +// XGrpMemNew -> ... +// XGrpMemRole -> ... +// XGrpMemDel -> ... +// -- how to vote - to depend on action (group - manual, update - automatic?); +// -- wait for voting always, or if event has unknown parents? (gaps in dag) +// -- how to treat delayed integrity violation - owner sending message to select members +// XGrpDel -> +// -- create "pending group deletion", wait for confirmation from majority of owners? +// -- new protocol requiring user action from other owners? +// XGrpInfo -> +// -- create "unconfirmed group profile update", remember prev group profile +// -- remove from "unconfirmed group profile update" when this event is in dag and not a leaf? +// -- if another group profile update event is received, revert "unconfirmed" event, don't apply new +// -- so if more than one update is received while dag is not merged to single vertice, all updates are not applied +// -- - this would likely lock out owners from any future updates +// -- - merge to new starting point after some time passes? +// -- - mark parents that are never received and so always block graph merging as special type? + +-- # admin events + +def processAdminEvent(ae: AdminEvent) = + lookup in owner dag - does member still have permission? + addAdminDagEvent(ae) + applyAdminDagEvent(ae) + +def addAdminDagEvent(ae: AdminEvent) = + if (any parent of ae not in dag): + buffer until all parents are in adminDag + else + add ae to adminDag + +def applyAdminDagEvent(ae: AdminEvent) = + case ae of + XGrpMemNew -> + -- handles case where messages from 2 admins about member addition and deletion arrive out of order + if member is not in "unconfirmed member deletions": + add member + XGrpMemRole -> + add role change to "unconfirmed role change" + -- remove from "unconfirmed role change" when this event is in dag and not a leaf? + if another role change already in "unconfirmed role change": + if new role is less than role in "unconfirmed role change": + change role -- role change applies in direction of lower role + XGrpMemDel -> + add member to "unconfirmed member deletions" + -- remove from "unconfirmed member deletions" when this event is in dag and not a leaf? + if member found by memberId: + delete member + +-- ^ problem: if later admin event turns out to fail integrity check, how to revert it? +-- member deletion: don't apply until in graph and not a leaf +-- role change: remember previous role and revert +-- member addition: delete member + +-- # message events + +def processMsgEvent(me: MsgEvent) = + lookup points in owner and admin dag? + - does member have permission to send event? (role changed/removed) + addMsgDagEvent(me) + applyMsgEvent(me) + +def addMsgDagEvent(me: MsgEvent) = + for me.parents not in msgDag: + add MDEPUnknown parent to msgDag + add me to msgDag + +def applyMsgEvent(me: MsgEvent) = + case me of + XMsgNew -> message to view + -- start process waiting for missing parents; if parents are not received: + -- can be shown as integrity violation if parents are not received + -- can be shown as integrity violation if other members don't refer it? + XMsgFileCancel -> cancel file immediately + -- wait for missing parents / referrals similarly to XMsgNew + -- restart file reception on integrity violation? + XMsgUpdate -> update to view -- same as XMsgNew + XMsgDel -> mark deleted, don't apply full delete until parents/referrals are received? + XMsgReact -> to view -- same as XMsgNew + XFile -> -- deprecate? + XFileCancel -> cancel -- same as XMsgFileCancel + XGrpLeave -> mark member as left, don't delete member connection immediately + -- member may try to maliciously remove connections selectively + -- wait for integrity check +``` + +# Admin blockchain + +Suppose admin DAG is replaced with blockchain, with a conflict resolution protocol to provide consistency of membership changes. Take Simplex (not to confuse with SimpleX chat) protocol (https://simplex.blog/). To reach BFT consensus and make progress, 2n/3 votes on block proposals are required, and it's assumed `f < n/3` where f is number of malicious actors. In a highly asynchronous setting of decentralized groups operated by mobile devices, progress seems unlikely or very slow. Should "admin participation" be hosted? From 4cc20a2d329d7ada46a81aa4a09f716a63d640ea Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 1 Nov 2023 21:52:45 +0800 Subject: [PATCH 78/80] android, desktop: block members (#3290) * android, desktop: block members * fixes * more fixes * fix * fix * color * color and icon --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 121 +++++- .../chat/simplex/common/model/SimpleXAPI.kt | 12 +- .../common/views/chat/ChatItemInfoView.kt | 2 +- .../simplex/common/views/chat/ChatView.kt | 156 +++++--- .../views/chat/group/GroupChatInfoView.kt | 53 ++- .../views/chat/group/GroupMemberInfoView.kt | 76 +++- .../views/chat/item/CIChatFeatureView.kt | 108 +++++- .../common/views/chat/item/CIEventView.kt | 9 +- .../common/views/chat/item/ChatItemView.kt | 358 ++++++++++++------ .../common/views/chat/item/FramedItemView.kt | 14 +- .../views/chat/item/MarkedDeletedItemView.kt | 62 ++- .../simplex/common/views/helpers/Section.kt | 28 ++ .../commonMain/resources/MR/base/strings.xml | 22 ++ .../resources/MR/images/ic_back_hand.svg | 1 + .../resources/MR/images/ic_collapse_all.svg | 1 + .../resources/MR/images/ic_do_not_touch.svg | 1 + .../resources/MR/images/ic_expand_all.svg | 1 + 17 files changed, 822 insertions(+), 203 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 767c678c1a..4d95bfd499 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -137,6 +137,7 @@ object ChatModel { fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } + fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId } private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } fun addChat(chat: Chat) = chats.add(index = 0, chat) @@ -442,6 +443,78 @@ object ChatModel { } } + fun getChatItemIndexOrNull(cItem: ChatItem): Int? { + val reversedChatItems = chatItems.asReversed() + val index = reversedChatItems.indexOfFirst { it.id == cItem.id } + return if (index != -1) index else null + } + + // this function analyses "connected" events and assumes that each member will be there only once + fun getConnectedMemberNames(cItem: ChatItem): Pair> { + var count = 0 + val ns = mutableListOf() + var idx = getChatItemIndexOrNull(cItem) + if (cItem.mergeCategory != null && idx != null) { + val reversedChatItems = chatItems.asReversed() + while (idx < reversedChatItems.size) { + val ci = reversedChatItems[idx] + if (ci.mergeCategory != cItem.mergeCategory) break + val m = ci.memberConnected + if (m != null) { + ns.add(m.displayName) + } + count++ + idx++ + } + } + return count to ns + } + + // returns the index of the passed item and the next item (it has smaller index) + fun getNextChatItem(ci: ChatItem): Pair { + val i = getChatItemIndexOrNull(ci) + return if (i != null) { + val reversedChatItems = chatItems.asReversed() + i to if (i > 0) reversedChatItems[i - 1] else null + } else { + null to null + } + } + + // returns the index of the first item in the same merged group (the first hidden item) + // and the previous visible item with another merge category + fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair { + var i = ciIndex ?: return null to null + val reversedChatItems = chatItems.asReversed() + val fst = reversedChatItems.lastIndex + while (i < fst) { + i++ + val ci = reversedChatItems[i] + if (ciCategory == null || ciCategory != ci.mergeCategory) { + return i - 1 to ci + } + } + return i to null + } + + // returns the previous member in the same merge group and the count of members in this group + fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair { + val reversedChatItems = chatItems.asReversed() + var prevMember: GroupMember? = null + val names: MutableSet = mutableSetOf() + for (i in range) { + val dir = reversedChatItems[i].chatDir + if (dir is CIDirection.GroupRcv) { + val m = dir.groupMember + if (prevMember == null && m.groupMemberId != member.groupMemberId) { + prevMember = m + } + names.add(m.groupMemberId) + } + } + return prevMember to names.size + } + // func popChat(_ id: String) { // if let i = getChatIndex(id) { // popChat_(i) @@ -474,7 +547,7 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { - val memberIndex = groupMembers.indexOfFirst { it.id == member.id } + val memberIndex = groupMembers.indexOfFirst { it.groupMemberId == member.groupMemberId } if (memberIndex >= 0) { groupMembers[memberIndex] = member false @@ -1090,11 +1163,11 @@ data class GroupMember ( val groupMemberId: Long, val groupId: Long, val memberId: String, - var memberRole: GroupMemberRole, - var memberCategory: GroupMemberCategory, - var memberStatus: GroupMemberStatus, - var memberSettings: GroupMemberSettings, - var invitedBy: InvitedBy, + val memberRole: GroupMemberRole, + val memberCategory: GroupMemberCategory, + val memberStatus: GroupMemberStatus, + val memberSettings: GroupMemberSettings, + val invitedBy: InvitedBy, val localDisplayName: String, val memberProfile: LocalProfile, val memberContactId: Long? = null, @@ -1467,7 +1540,7 @@ data class ChatItem ( chatController.appPrefs.privacyEncryptLocalFiles.get() val memberDisplayName: String? get() = - if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName + if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.chatViewName else null val isDeletedContent: Boolean get() = @@ -1491,6 +1564,29 @@ data class ChatItem ( else -> null } + val mergeCategory: CIMergeCategory? + get() = when (content) { + is CIContent.RcvChatFeature, + is CIContent.SndChatFeature, + is CIContent.RcvGroupFeature, + is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature + is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { + is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null + else -> CIMergeCategory.RcvGroupEvent + } + is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null + else -> CIMergeCategory.SndGroupEvent + } + else -> { + if (meta.itemDeleted == null) { + null + } else { + if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + } + } + } + fun memberToModerate(chatInfo: ChatInfo): Pair? { return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { val m = chatInfo.groupInfo.membership @@ -1695,6 +1791,15 @@ data class ChatItem ( } } +enum class CIMergeCategory { + MemberConnected, + RcvGroupEvent, + SndGroupEvent, + SndItemDeleted, + RcvItemDeleted, + ChatFeature, +} + @Serializable sealed class CIDirection { @Serializable @SerialName("directSnd") class DirectSnd: CIDirection() @@ -1895,7 +2000,9 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent() @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent() + // legacy - since v4.3.0 itemDeleted field is used @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } + // legacy - since v4.3.0 itemDeleted field is used @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7ce508f662..9a9b48a35e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -745,6 +745,9 @@ object ChatController { } } + suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = + sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiContactInfo(contactId: Long): Pair? { val r = sendCmd(CC.APIContactInfo(contactId)) if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile @@ -1926,6 +1929,7 @@ sealed class CC { class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() + class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC() class APIContactInfo(val contactId: Long): CC() class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APISwitchContact(val contactId: Long): CC() @@ -2036,6 +2040,7 @@ sealed class CC { is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" + is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APISwitchContact -> "/_switch @$contactId" @@ -2139,9 +2144,10 @@ sealed class CC { is APITestProtoServer -> "testProtoServer" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" - is APISetNetworkConfig -> "/apiSetNetworkConfig" - is APIGetNetworkConfig -> "/apiGetNetworkConfig" - is APISetChatSettings -> "/apiSetChatSettings" + is APISetNetworkConfig -> "apiSetNetworkConfig" + is APIGetNetworkConfig -> "apiGetNetworkConfig" + is APISetChatSettings -> "apiSetChatSettings" + is ApiSetMemberSettings -> "apiSetMemberSettings" is APIContactInfo -> "apiContactInfo" is APIGroupMemberInfo -> "apiGroupMemberInfo" is APISwitchContact -> "apiSwitchContact" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 53a5fd9d62..9b69ddb5a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -382,7 +382,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> { return memberDeliveryStatuses.mapNotNull { mds -> - chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem -> + chatModel.getGroupMember(mds.groupMemberId)?.let { mem -> mem to mds.memberDeliveryStatus } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index ac7161044f..e724dfd7cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -152,6 +152,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null + chatModel.groupMembers.clear() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -212,7 +213,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: setGroupMembers(groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) } } @@ -263,6 +264,25 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, + deleteMessages = { itemIds -> + if (itemIds.isNotEmpty()) { + val chatInfo = chat.chatInfo + withBGApi { + val deletedItems: ArrayList = arrayListOf() + for (itemId in itemIds) { + val di = chatModel.controller.apiDeleteChatItem( + chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal + )?.deletedChatItem?.chatItem + if (di != null) { + deletedItems.add(di) + } + } + for (di in deletedItems) { + chatModel.removeChatItem(chatInfo, di) + } + } + } + }, receiveFile = { fileId, encrypted -> withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } }, @@ -442,6 +462,7 @@ fun ChatLayout( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -517,7 +538,7 @@ fun ChatLayout( ) { ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, - useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, + useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, @@ -744,6 +765,7 @@ fun BoxWithConstraintsScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -846,31 +868,27 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null - if (chat.chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null - val nextItem = if (i - 1 >= 0) reversedChatItems[i - 1] else null - fun getConnectedMemberNames(): List { - val ns = mutableListOf() - var idx = i - while (idx < reversedChatItems.size) { - val m = reversedChatItems[idx].memberConnected - if (m != null) { - ns.add(m.displayName) - } else { - break - } - idx++ - } - return ns - } - if (cItem.memberConnected != null && nextItem?.memberConnected != null) { - // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView - Box(Modifier.size(0.dp)) {} - } else { + + val revealed = remember { mutableStateOf(false) } + + @Composable + fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } + + @Composable + fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) { + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null + if (chat.chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { val member = cItem.chatDir.groupMember - if (showMemberImage(member, prevItem)) { + val (prevMember, memCount) = + if (range != null) { + chatModel.getPrevHiddenMember(member, range) + } else { + null to 1 + } + if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) { Column( Modifier .padding(top = 8.dp) @@ -880,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { if (cItem.content.showMemberName) { Text( - member.displayName, + memberNames(member, prevMember, memCount), Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp), style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) ) @@ -898,7 +916,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { MemberImage(member) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } } else { @@ -907,28 +925,45 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } + } else { + Box( + Modifier + .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) + .then(swipeableModifier) + ) { + ChatItemViewShortHand(cItem, range) + } } - } else { + } else { // direct message + val sent = cItem.chatDir.sent Box( - Modifier - .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) - .then(swipeableModifier) + Modifier.padding( + start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, + ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } - } else { // direct message - val sent = cItem.chatDir.sent - Box( - Modifier.padding( - start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, - end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, - ).then(swipeableModifier) - ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } + + val (currIndex, nextItem) = chatModel.getNextChatItem(cItem) + val ciCategory = cItem.mergeCategory + if (ciCategory != null && ciCategory == nextItem?.mergeCategory) { + // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView + } else { + val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val range = chatViewItemsRange(currIndex, prevHidden) + if (revealed.value && range != null) { + reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci -> + val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1] + ChatItemView(ci, null, prev) + } + } else { + ChatItemView(cItem, range, prevItem) } } @@ -1106,10 +1141,12 @@ fun PreloadItems( } } -fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean { - return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd || - (prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId) -} +private fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean = + when (val dir = prevItem?.chatDir) { + is CIDirection.GroupSnd -> true + is CIDirection.GroupRcv -> dir.groupMember.groupMemberId != member.groupMemberId + else -> false + } val MEMBER_IMAGE_SIZE: Dp = 38.dp @@ -1206,6 +1243,29 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha } } +@Composable +private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String { + val name = member.displayName + val prevName = prevMember?.displayName + return if (prevName != null) { + if (memCount > 2) { + stringResource(MR.strings.group_members_n).format(name, prevName, memCount - 2) + } else { + stringResource(MR.strings.group_members_2).format(name, prevName) + } + } else { + name + } +} + +fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? = + if (currIndex != null && prevHidden != null && prevHidden > currIndex) { + currIndex..prevHidden + } else { + null + } + + sealed class ProviderMedia { data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia() data class Video(val uri: URI, val preview: String): ProviderMedia() @@ -1347,6 +1407,7 @@ fun PreviewChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -1418,6 +1479,7 @@ fun PreviewGroupChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, + deleteMessages = {}, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index f475d045cf..8c9619703b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -4,10 +4,12 @@ import InfoRow import SectionBottomSpacer import SectionDividerSpaced import SectionItemView +import SectionItemViewLongClickable import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material.* @@ -31,6 +33,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -82,7 +85,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR member to null } ModalManager.end.showModalCloseable(true) { closeCurrent -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { closeCurrent() close() @@ -157,6 +160,23 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U ) } +private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.button_remove_member_question), + text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), + confirmText = generalGetString(MR.strings.remove_member_confirmation), + onConfirm = { + withApi { + val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + if (updatedMember != null) { + chatModel.upsertGroupMember(groupInfo, updatedMember) + } + } + }, + destructive = true, + ) +} + @Composable fun GroupChatInfoLayout( chat: Chat, @@ -238,8 +258,10 @@ fun GroupChatInfoLayout( } items(filteredMembers.value) { member -> Divider() - SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) { - MemberRow(member) + val showMenu = remember { mutableStateOf(false) } + SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) { + DropDownMenuForMember(member, groupInfo, showMenu) + MemberRow(member, onClick = { showMemberInfo(member) }) } } item { @@ -344,7 +366,7 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick } @Composable -private fun MemberRow(member: GroupMember, user: Boolean = false) { +private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -390,6 +412,29 @@ private fun MemberVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary) } +@Composable +private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { + DefaultDropdownMenu(showMenu) { + if (member.canBeRemoved(groupInfo)) { + ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + removeMemberAlert(groupInfo, member) + showMenu.value = false + }) + } + if (member.memberSettings.showMessages) { + ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { + blockMemberAlert(groupInfo, member) + showMenu.value = false + }) + } else { + ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { + unblockMemberAlert(groupInfo, member) + showMenu.value = false + }) + } + } +} + @Composable private fun GroupLinkButton(onClick: () -> Unit) { SettingsActionItem( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e486aca4a0..9f52f61deb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -96,6 +96,8 @@ fun GroupMemberInfoView( connectViaAddress = { connReqUri -> connectViaMemberAddressAlert(connReqUri) }, + blockMember = { blockMemberAlert(groupInfo, member) }, + unblockMember = { unblockMemberAlert(groupInfo, member) }, removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout @@ -162,7 +164,7 @@ fun GroupMemberInfoView( }, verifyClicked = { ModalManager.end.showModalCloseable { close -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> VerifyCodeView( mem.displayName, connectionCode, @@ -224,6 +226,8 @@ fun GroupMemberInfoLayout( openDirectChat: (Long) -> Unit, createMemberContact: () -> Unit, connectViaAddress: (String) -> Unit, + blockMember: () -> Unit, + unblockMember: () -> Unit, removeMember: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, @@ -338,9 +342,14 @@ fun GroupMemberInfoLayout( } } - if (member.canBeRemoved(groupInfo)) { - SectionDividerSpaced(maxBottomPadding = false) - SectionView { + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + if (member.memberSettings.showMessages) { + BlockMemberButton(blockMember) + } else { + UnblockMemberButton(unblockMember) + } + if (member.canBeRemoved(groupInfo)) { RemoveMemberButton(removeMember) } } @@ -396,6 +405,26 @@ fun GroupMemberInfoHeader(member: GroupMember) { } } +@Composable +fun BlockMemberButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_back_hand), + stringResource(MR.strings.block_member_button), + click = onClick, + textColor = Color.Red, + iconColor = Color.Red, + ) +} + +@Composable +fun UnblockMemberButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_do_not_touch), + stringResource(MR.strings.unblock_member_button), + click = onClick + ) +} + @Composable fun RemoveMemberButton(onClick: () -> Unit) { SettingsActionItem( @@ -485,6 +514,43 @@ fun connectViaMemberAddressAlert(connReqUri: String) { } } +fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.block_member_question), + text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.block_member_confirmation), + onConfirm = { + toggleShowMemberMessages(gInfo, mem, false) + }, + destructive = true, + ) +} + +fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unblock_member_question), + text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.unblock_member_confirmation), + onConfirm = { + toggleShowMemberMessages(gInfo, mem, true) + }, + ) +} + +fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { + val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages) + updateMemberSettings(gInfo, member, updatedMemberSettings) +} + +fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { + withBGApi { + val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + if (success) { + ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings)) + } + } +} + @Preview @Composable fun PreviewGroupMemberInfoLayout() { @@ -500,6 +566,8 @@ fun PreviewGroupMemberInfoLayout() { openDirectChat = {}, createMemberContact = {}, connectViaAddress = {}, + blockMember = {}, + unblockMember = {}, removeMember = {}, onRoleSelected = {}, switchMemberAddress = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index e919ab1aa8..63d07627ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -1,19 +1,119 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatItem -import chat.simplex.common.model.Feature +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull +import chat.simplex.common.views.helpers.onRightClick @Composable fun CIChatFeatureView( + chatItem: ChatItem, + feature: Feature, + iconColor: Color, + icon: Painter? = null, + revealed: MutableState, + showMenu: MutableState, +) { + val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList() + Box( + Modifier + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = {} + ) + .onRightClick { showMenu.value = true } + ) { + if (!revealed.value && merged != null) { + Row( + Modifier.padding(horizontal = 6.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + merged.forEach { + FeatureIconView(it) + } + } + } else { + FullFeatureView(chatItem, feature, iconColor, icon) + } + } +} + +private data class FeatureInfo( + val icon: PainterBox, + val color: Color, + val param: String? +) + +private class PainterBox( + val featureName: String, + val icon: Painter, +) { + override fun hashCode(): Int = featureName.hashCode() + override fun equals(other: Any?): Boolean = other is PainterBox && featureName == other.featureName +} + +@Composable +private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): FeatureInfo = + FeatureInfo( + icon = PainterBox(type, iconFilled()), + color = color, + param = if (this.hasParam && param != null) timeText(param) else null + ) + +@Composable +private fun mergedFeatures(chatItem: ChatItem): List? { + val m = ChatModel + val fs: ArrayList = arrayListOf() + val icons: MutableSet = mutableSetOf() + var i = getChatItemIndexOrNull(chatItem) + if (i != null) { + val reversedChatItems = m.chatItems.asReversed() + while (i < reversedChatItems.size) { + val f = featureInfo(reversedChatItems[i]) ?: break + if (!icons.contains(f.icon)) { + fs.add(0, f) + icons.add(f.icon) + } + i++ + } + } + return if (fs.size > 1) fs else null +} + +@Composable +private fun featureInfo(ci: ChatItem): FeatureInfo? = + when (ci.content) { + is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) + is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) + is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + else -> null + } + +@Composable +private fun FeatureIconView(f: FeatureInfo) { + val icon = @Composable { Icon(f.icon.icon, null, Modifier.size(20.dp), tint = f.color) } + if (f.param != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + icon() + Text(chatEventText(f.param, ""), maxLines = 1) + } + } else { + icon() + } +} + +@Composable +private fun FullFeatureView( chatItem: ChatItem, feature: Feature, iconColor: Color, @@ -24,7 +124,7 @@ fun CIChatFeatureView( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor) + Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(20.dp), tint = iconColor) Text( chatEventText(chatItem), Modifier, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt index 508116f7e3..1e20a372ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt @@ -1,11 +1,9 @@ package chat.simplex.common.views.chat.item import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.unit.dp @@ -14,12 +12,7 @@ import chat.simplex.common.ui.theme.* @Composable fun CIEventView(text: AnnotatedString) { - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)) - } + Text(text, Modifier.padding(horizontal = 6.dp, vertical = 6.dp), style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)) } @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index dd9fe4d4a8..d2dcfcaeec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -21,9 +21,8 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.chat.ComposeContextItem -import chat.simplex.common.views.chat.ComposeState import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -47,7 +46,10 @@ fun ChatItemView( imageProvider: (() -> ImageGalleryProvider)? = null, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + revealed: MutableState, + range: IntRange?, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit, @@ -63,14 +65,12 @@ fun ChatItemView( findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, - getConnectedMemberNames: (() -> List)? = null, developerTools: Boolean, ) { val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } - val revealed = remember { mutableStateOf(false) } val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val onLinkLongClick = { _: String -> showMenu.value = true } val live = composeState.value.liveMessage != null @@ -178,61 +178,75 @@ fun ChatItemView( fun MsgContentItemDropdownMenu() { val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) DefaultDropdownMenu(showMenu) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } - showMenu.value = false - }) - } - val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) + if (cItem.content.msgContent != null) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { - SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + if (cItem.meta.itemDeleted == null && !live) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + val clipboard = LocalClipboardManager.current + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + val fileSource = getLoadedFileSource(cItem.file) + when { + fileSource != null -> shareFile(cItem.text, fileSource) + else -> clipboard.shareText(cItem.content.text) + } showMenu.value = false }) - } - if (cItem.meta.itemDeleted != null && revealed.value) { - ItemAction( - stringResource(MR.strings.hide_verb), - painterResource(MR.images.ic_visibility_off), - onClick = { - revealed.value = false + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false - } - ) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - if (!(live && cItem.meta.isLive)) { - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + }) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (revealed.value) { + HideItemAction(revealed, showMenu) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } + } else if (cItem.meta.itemDeleted != null) { + if (revealed.value) { + HideItemAction(revealed, showMenu) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu) + } else if (range != null) { + ExpandItemAction(revealed, showMenu) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } else if (cItem.isDeletedContent) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } else if (cItem.mergeCategory != null) { + if (revealed.value) { + ShrinkItemAction(revealed, showMenu) + } else { + ExpandItemAction(revealed, showMenu) + } } } } @@ -241,25 +255,18 @@ fun ChatItemView( fun MarkedDeletedItemDropdownMenu() { DefaultDropdownMenu(showMenu) { if (!cItem.isDeletedContent) { - ItemAction( - stringResource(MR.strings.reveal_verb), - painterResource(MR.images.ic_visibility), - onClick = { - revealed.value = true - showMenu.value = false - } - ) + RevealItemAction(revealed, showMenu) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } } @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && !revealed.value) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL) + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -281,7 +288,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } } @@ -289,9 +296,32 @@ fun ChatItemView( CICallItemView(cInfo, cItem, status, duration, acceptCall) } + fun mergedGroupEventText(chatItem: ChatItem): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem) + val members = when { + ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) + ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) + ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) + ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) + else -> "" + } + return if (count <= 1) { + null + } else if (ns.isEmpty()) { + generalGetString(MR.strings.rcv_group_events_count).format(count) + } else if (count > ns.size) { + members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) + } else { + members + } + } + fun eventItemViewText(): AnnotatedString { val memberDisplayName = cItem.memberDisplayName - return if (memberDisplayName != null) { + val t = mergedGroupEventText(cItem) + return if (!revealed.value && t != null) { + chatEventText(t, cItem.timestampText) + } else if (memberDisplayName != null) { buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") @@ -305,35 +335,12 @@ fun ChatItemView( CIEventView(eventItemViewText()) } - fun membersConnectedText(): String? { - return if (getConnectedMemberNames != null) { - val ns = getConnectedMemberNames() - when { - ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) - ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) - ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) - else -> null - } - } else { - null - } - } - - fun membersConnectedItemText(): AnnotatedString { - val t = membersConnectedText() - return if (t != null) { - chatEventText(t, cItem.timestampText) - } else { - eventItemViewText() - } - } - @Composable fun ModeratedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL) + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) } } @@ -352,26 +359,61 @@ fun ChatItemView( is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) - is CIContent.RcvDirectEventContent -> EventItemView() - is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText()) - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - else -> EventItemView() + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() } - is CIContent.SndGroupEventContent -> EventItemView() - is CIContent.RcvConnEventContent -> EventItemView() - is CIContent.SndConnEventContent -> EventItemView() - is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) - is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) is CIContent.RcvChatPreference -> { val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) } - is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,) - is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) - is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) + is CIContent.SndChatPreference -> { + CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } is CIContent.SndModerated -> ModeratedItem() is CIContent.RcvModerated -> ModeratedItem() is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) @@ -430,16 +472,38 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( cItem: ChatItem, + revealed: MutableState, showMenu: MutableState, questionText: String, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, ) { ItemAction( stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), onClick = { showMenu.value = false - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + if (!revealed.value && cItem.meta.itemDeleted != null) { + val currIndex = chatModel.getChatItemIndexOrNull(cItem) + val ciCategory = cItem.mergeCategory + if (currIndex != null && ciCategory != null) { + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val range = chatViewItemsRange(currIndex, prevHidden) + if (range != null) { + val itemIds: ArrayList = arrayListOf() + for (i in range) { + itemIds.add(chatModel.chatItems.asReversed()[i].id) + } + deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages) + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } }, color = Color.Red ) @@ -463,6 +527,54 @@ fun ModerateItemAction( ) } +@Composable +private fun RevealItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.reveal_verb), + painterResource(MR.images.ic_visibility), + onClick = { + revealed.value = true + showMenu.value = false + } + ) +} + +@Composable +private fun HideItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.hide_verb), + painterResource(MR.images.ic_visibility_off), + onClick = { + revealed.value = false + showMenu.value = false + } + ) +} + +@Composable +private fun ExpandItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.expand_verb), + painterResource(MR.images.ic_expand_all), + onClick = { + revealed.value = true + showMenu.value = false + }, + ) +} + +@Composable +private fun ShrinkItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.hide_verb), + painterResource(MR.images.ic_collapse_all), + onClick = { + revealed.value = false + showMenu.value = false + }, + ) +} + @Composable fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) { val finalColor = if (color == Color.Unspecified) { @@ -542,6 +654,26 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } +fun deleteMessagesAlertDialog(itemIds: List, questionText: String, deleteMessages: (List) -> Unit) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size), + text = questionText, + buttons = { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { + deleteMessages(itemIds) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + } + } + ) +} + fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), @@ -575,7 +707,10 @@ fun PreviewChatItemView() { useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + revealed = remember { mutableStateOf(false) }, + range = 0..1, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, @@ -606,7 +741,10 @@ fun PreviewChatItemViewDeletedContent() { useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + revealed = remember { mutableStateOf(false) }, + range = 0..1, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, joinGroup = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 122e54c3b2..1d3e8bb202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -202,10 +202,16 @@ fun FramedItemView( Column(Modifier.width(IntrinsicSize.Max)) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { if (ci.meta.itemDeleted != null) { - if (ci.meta.itemDeleted is CIDeleted.Moderated) { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) - } else { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + else -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } } } else if (ci.meta.isLive) { FramedItemHeader(stringResource(MR.strings.live), false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 84675a09b4..50d905ef76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -2,25 +2,25 @@ package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.CIDeleted -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { +fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState) { val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( @@ -32,11 +32,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - if (ci.meta.itemDeleted is CIDeleted.Moderated) { - MarkedDeletedText(String.format(generalGetString(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName)) - } else { - MarkedDeletedText(generalGetString(MR.strings.marked_deleted_description)) - } + MergedMarkedDeletedText(ci, revealed) } CIMetaView(ci, timedMessagesTTL) } @@ -44,7 +40,41 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { } @Composable -private fun MarkedDeletedText(text: String) { +private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState) { + var i = getChatItemIndexOrNull(chatItem) + val ciCategory = chatItem.mergeCategory + val text = if (!revealed.value && ciCategory != null && i != null) { + val reversedChatItems = ChatModel.chatItems.asReversed() + var moderated = 0 + var blocked = 0 + var deleted = 0 + val moderatedBy: MutableSet = mutableSetOf() + while (i < reversedChatItems.size) { + val ci = reversedChatItems.getOrNull(i) + if (ci?.mergeCategory != ciCategory) break + when (val itemDeleted = ci.meta.itemDeleted ?: break) { + is CIDeleted.Moderated -> { + moderated += 1 + moderatedBy.add(itemDeleted.byGroupMember.displayName) + } + is CIDeleted.Blocked -> blocked += 1 + is CIDeleted.Deleted -> deleted += 1 + } + i++ + } + val total = moderated + blocked + deleted + if (total <= 1) + markedDeletedText(chatItem.meta) + else if (total == moderated) + stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) + else if (total == blocked) + stringResource(MR.strings.blocked_items_description).format(total) + else + stringResource(MR.strings.marked_deleted_items_description).format(total) + } else { + markedDeletedText(chatItem.meta) + } + Text( buildAnnotatedString { withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) } @@ -56,6 +86,16 @@ private fun MarkedDeletedText(text: String) { ) } +private fun markedDeletedText(meta: CIMeta): String = + when (meta.itemDeleted) { + is CIDeleted.Moderated -> + String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) + is CIDeleted.Blocked -> + generalGetString(MR.strings.blocked_item_description) + else -> + generalGetString(MR.strings.marked_deleted_description) + } + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 6adbfed76c..c6ae0d6d48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -98,6 +98,34 @@ fun SectionItemView( } } +@Composable +fun SectionItemViewLongClickable( + click: () -> Unit, + longClick: () -> Unit, + minHeight: Dp = 46.dp, + disabled: Boolean = false, + extraPadding: Boolean = false, + padding: PaddingValues = if (extraPadding) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + else + PaddingValues(horizontal = DEFAULT_PADDING), + content: (@Composable RowScope.() -> Unit) +) { + val modifier = Modifier + .fillMaxWidth() + .sizeIn(minHeight = minHeight) + Row( + if (disabled) { + modifier.padding(padding) + } else { + modifier.combinedClickable(onClick = click, onLongClick = longClick).onRightClick(longClick).padding(padding) + }, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + @Composable fun SectionItemViewWithIcon( click: (() -> Unit)? = null, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index aa76a768e2..9df3f9d621 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -30,7 +30,11 @@ deleted marked deleted + %d messages marked deleted moderated by %s + %d messages moderated by %s + blocked + %d messages blocked sending files is not supported yet receiving files is not supported yet you @@ -243,7 +247,9 @@ Hide Allow Moderate + Expand Delete message? + Delete %d messages? Message will be deleted - this cannot be undone! Message will be marked for deletion. The recipient(s) will be able to reveal this message. Delete member message? @@ -1132,9 +1138,14 @@ you left group profile updated + %s connected %s and %s connected %s, %s and %s connected %s, %s and %d other members connected + %d group events + and %d other events + %s and %s + %s, %s and %d members Open @@ -1250,10 +1261,21 @@ %s: %s + Remove member? Remove member + Send direct message Member will be removed from group - this cannot be undone! Remove + Remove member + Block member? + Block member + Block + All new messages from %s will be hidden! + Unblock member? + Unblock member + Unblock + Messages from %s will be shown! MEMBER Role Change role diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg new file mode 100644 index 0000000000..41013ff66c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg new file mode 100644 index 0000000000..a380594e79 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg new file mode 100644 index 0000000000..5ea1a5f2e3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg new file mode 100644 index 0000000000..75b2874d5b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg @@ -0,0 +1 @@ + \ No newline at end of file From 4fd38a270c5777450705b5810b056eb369d013a0 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 2 Nov 2023 02:23:41 +0800 Subject: [PATCH 79/80] desktop: adding build version code to UI (#3304) --- apps/multiplatform/common/build.gradle.kts | 1 + .../commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt | 2 +- .../chat/simplex/common/views/usersettings/VersionInfoView.kt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 13ca2c309d..7100973165 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -138,6 +138,7 @@ buildConfig { buildConfigField("String", "ANDROID_VERSION_NAME", "\"${extra["android.version_name"]}\"") buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}") buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") + buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index d36a6aec16..b10a30233f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -21,7 +21,7 @@ expect val appPlatform: AppPlatform val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else - BuildConfigCommon.DESKTOP_VERSION_NAME to null + BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 8528414771..010b94e034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -24,6 +24,7 @@ fun VersionInfoView(info: CoreVersionInfo) { Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) } else { Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME)) + Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.DESKTOP_VERSION_CODE)) } Text(String.format(stringResource(MR.strings.core_version), info.version)) val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit From fad5128a832fe70684993c30dbbc50f1a3e853c4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 2 Nov 2023 03:11:04 +0800 Subject: [PATCH 80/80] android, desktop: updated Compose and changed mac notarization tool (#3303) * android, desktop: updated Compose and changed mac notarization tool * imports * desktop (mac): fix lib building * imports --------- Co-authored-by: Avently Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/multiplatform/android/build.gradle.kts | 4 +- apps/multiplatform/build.gradle.kts | 2 +- apps/multiplatform/common/build.gradle.kts | 2 +- .../common/platform/Modifier.android.kt | 2 + .../chatlist/ChatListNavLinkView.android.kt | 1 + .../helpers/DefaultDropDownMenu.android.kt | 48 ------------------- .../chat/simplex/common/platform/Modifier.kt | 2 + .../common/views/chat/ChatItemInfoView.kt | 1 + .../views/chat/item/CIChatFeatureView.kt | 2 +- .../common/views/chat/item/FramedItemView.kt | 3 +- .../views/chat/item/ImageFullScreenView.kt | 9 +++- .../views/helpers/DefaultDropdownMenu.kt | 24 +--------- .../helpers/ExposedDropDownSettingRow.kt | 2 +- .../simplex/common/views/helpers/Section.kt | 1 + .../usersettings/AdvancedNetworkSettings.kt | 4 +- .../common/platform/Modifier.desktop.kt | 3 ++ .../views/chat/item/CIVideoView.desktop.kt | 4 +- .../chatlist/ChatListNavLinkView.desktop.kt | 1 + .../helpers/DefaultDropDownMenu.desktop.kt | 44 ----------------- apps/multiplatform/desktop/build.gradle.kts | 2 +- apps/multiplatform/gradle.properties | 2 +- scripts/desktop/build-lib-mac.sh | 5 ++ 22 files changed, 37 insertions(+), 131 deletions(-) delete mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 873f33b22f..a35d3f5195 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } android { - compileSdkVersion(33) + compileSdkVersion(34) defaultConfig { applicationId = "chat.simplex.app" @@ -144,7 +144,7 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") //androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") - debugImplementation("androidx.compose.ui:ui-tooling:${rootProject.extra["compose.version"] as String}") + debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") } tasks { diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 3a6fbcbf94..9d6fd0c20b 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -36,7 +36,7 @@ buildscript { extra.set("desktop.mac.signing.keychain", prop["desktop.mac.signing.keychain"] ?: extra.getOrNull("compose.desktop.mac.signing.keychain")) extra.set("desktop.mac.notarization.apple_id", prop["desktop.mac.notarization.apple_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.appleID")) extra.set("desktop.mac.notarization.password", prop["desktop.mac.notarization.password"] ?: extra.getOrNull("compose.desktop.mac.notarization.password")) - extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.ascProvider")) + extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.teamID")) repositories { google() diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 7100973165..55e03f6209 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -107,7 +107,7 @@ kotlin { } android { - compileSdkVersion(33) + compileSdkVersion(34) sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { minSdkVersion(26) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 41349654be..115027c1a0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -23,3 +23,5 @@ actual fun Modifier.desktopOnExternalDrag( onImage: (Painter) -> Unit, onText: (String) -> Unit ): Modifier = this + +actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt index 3f33913e54..30f5b81387 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.helpers.* @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt deleted file mode 100644 index 38dd78dd99..0000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt +++ /dev/null @@ -1,48 +0,0 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupProperties - -actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this - -actual interface DefaultExposedDropdownMenuBoxScope { - @Composable - actual fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - content: @Composable ColumnScope.() -> Unit - ) { - DropdownMenu(expanded, onDismissRequest, modifier, content = content) - } - - @Composable - fun DropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 0.dp), - properties: PopupProperties = PopupProperties(focusable = true), - content: @Composable ColumnScope.() -> Unit - ) { - androidx.compose.material.DropdownMenu(expanded, onDismissRequest, modifier, offset, properties, content) - } -} - -@Composable -actual fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) { - val scope = remember { object : DefaultExposedDropdownMenuBoxScope {} } - androidx.compose.material.ExposedDropdownMenuBox(expanded, onExpandedChange, modifier, content = { - scope.content() - }) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index 543444d0eb..a143057a32 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -20,3 +20,5 @@ expect fun Modifier.desktopOnExternalDrag( onImage: (Painter) -> Unit = {}, onText: (String) -> Unit = {} ): Modifier + +expect fun Modifier.onRightClick(action: () -> Unit): Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 9b69ddb5a0..63cd25092e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 63d07627ca..a9a4963c96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull -import chat.simplex.common.views.helpers.onRightClick +import chat.simplex.common.platform.onRightClick @Composable fun CIChatFeatureView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 1d3e8bb202..c391200c2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -20,10 +20,9 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.platform.base64ToBitmap import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE import chat.simplex.res.MR import kotlin.math.min diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 4d4d847cc7..c7268592bf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -32,7 +32,12 @@ interface ImageGalleryProvider { @Composable fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { val provider = remember { imageProvider() } - val pagerState = rememberPagerState(provider.initialIndex) + val pagerState = rememberPagerState( + initialPage = provider.initialIndex, + initialPageOffsetFraction = 0f + ) { + provider.totalMediaSize.value + } val goBack = { provider.onDismiss(pagerState.currentPage); close() } BackHandler(onBack = goBack) // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, @@ -138,7 +143,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } } if (appPlatform.isAndroid) { - HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) } + HorizontalPager(state = pagerState) { index -> Content(index) } } else { Content(pagerState.currentPage) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt index f3aec77e0b..267fc86462 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt @@ -11,26 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -expect fun Modifier.onRightClick(action: () -> Unit): Modifier - -expect interface DefaultExposedDropdownMenuBoxScope { - @Composable - open fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit - ) -} - -@Composable -expect fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) - @Composable fun DefaultDropdownMenu( showMenu: MutableState, @@ -55,7 +35,7 @@ fun DefaultDropdownMenu( } @Composable -fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( +fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( expanded: MutableState, modifier: Modifier = Modifier, dropdownMenuItems: (@Composable () -> Unit)? @@ -63,7 +43,7 @@ fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( MaterialTheme( shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp))) ) { - DefaultExposedDropdownMenu( + ExposedDropdownMenu( modifier = Modifier .widthIn(min = 200.dp) .background(MaterialTheme.colors.surface) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 2f24ff4144..72a8aaf10a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -29,7 +29,7 @@ fun ExposedDropDownSettingRow( ) { SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) { val expanded = remember { mutableStateOf(false) } - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value && enabled.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index c6ae0d6d48..16d7e88d6f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import chat.simplex.common.platform.onRightClick import chat.simplex.common.platform.windowWidth import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index eedf604a7f..5849178202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -254,7 +254,7 @@ fun IntSettingRow(title: String, selection: MutableState, values: List Text(title) - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value @@ -313,7 +313,7 @@ fun TimeoutSettingRow(title: String, selection: MutableState, values: List Text(title) - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index 0185a50fcf..fa9f311d1b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -1,5 +1,6 @@ package chat.simplex.common.platform +import androidx.compose.foundation.contextMenuOpenDetector import androidx.compose.runtime.Composable import androidx.compose.ui.* import androidx.compose.ui.graphics.painter.Painter @@ -29,3 +30,5 @@ onExternalDrag(enabled) { is DragData.Text -> onText(data.readText()) } } + +actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index 7a46873f7d..8dac39199f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -6,9 +6,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp -import chat.simplex.common.platform.VideoPlayer -import chat.simplex.common.platform.isPlaying -import chat.simplex.common.views.helpers.onRightClick +import chat.simplex.common.platform.* @Composable actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 2b646f0e46..6c37c93ccc 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.helpers.* object NoIndication : Indication { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt deleted file mode 100644 index 5dfd44ba6b..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt +++ /dev/null @@ -1,44 +0,0 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.material.DropdownMenu -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp - -actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } - -actual interface DefaultExposedDropdownMenuBoxScope { - @Composable - actual fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - content: @Composable ColumnScope.() -> Unit - ) { - DropdownMenu(expanded, onDismissRequest, offset = DpOffset(0.dp, (-40).dp)) { - Column { - content() - } - } - } -} - -@Composable -actual fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) { - val obj = remember { object : DefaultExposedDropdownMenuBoxScope {} } - Box(Modifier - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { onExpandedChange(!expanded) }) - ) { - obj.content() - } -} diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index a7dab78ee8..ea808a32db 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -88,7 +88,7 @@ compose { notarization { this.appleID.set(appleId) this.password.set(password) - this.ascProvider.set(teamId) + this.teamID.set(teamId) } } } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 3474208140..d1ce585346 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -33,4 +33,4 @@ desktop.version_code=15 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 -compose.version=1.4.3 +compose.version=1.5.10 diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 3680a4a2aa..c33f59253f 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -112,6 +112,11 @@ if [ -n "$LIBCRYPTO_PATH" ]; then install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsmplxmq*.$LIB_EXT fi +LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsqlcphr-*.$LIB_EXT +fi + for lib in $(find . -type f -name "*.$LIB_EXT"); do RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11` for RPATH in $RPATHS; do