From 79ac515f6ce3d4065ad467426cab746516c6ef40 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:04:28 +0000 Subject: [PATCH 01/81] core: fix delivery cursor not advancing to maximum group member id for posgtgres (#7043) --- src/Simplex/Chat/Store/Delivery.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 75345e5e86..eeef3e0181 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -370,7 +370,7 @@ getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} curs map (toContactMember vr user) <$> DB.query db - (groupMemberQuery <> " WHERE m.group_member_id IN ?") + (groupMemberQuery <> " WHERE m.group_member_id IN ? ORDER BY m.group_member_id ASC") (Only (In gmIds)) #else rights <$> mapM (runExceptT . getGroupMemberById db vr user) gmIds From 656b1a3b6468e1b3295489ca855ccd5e4404be42 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:53:27 +0000 Subject: [PATCH 02/81] simplex-chat-nodejs: bump libraries (#7042) --- packages/simplex-chat-nodejs/src/download-libs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index db042d48a2..72761a1ac5 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v6.5.2'; +const RELEASE_TAG = 'v6.5.4'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { From 5b93cb0e3fe6b07d84f18168b2b90d95487137c2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 6 Jun 2026 20:48:11 +0100 Subject: [PATCH 03/81] core: store context to pass configuration parameters (#7057) * core: store context to pass configuration parameters * fix directory * fix test * comment * order --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../src/Directory/Service.hs | 16 +- .../src/Directory/Store.hs | 35 +- .../src/Directory/Store/Migrate.hs | 23 +- .../src/Directory/Util.hs | 6 +- src/Simplex/Chat/Controller.hs | 6 + src/Simplex/Chat/Library/Commands.hs | 636 +++++++++--------- src/Simplex/Chat/Library/Internal.hs | 198 +++--- src/Simplex/Chat/Library/Subscriber.hs | 250 +++---- src/Simplex/Chat/Store/Connections.hs | 26 +- src/Simplex/Chat/Store/ContactRequest.hs | 20 +- src/Simplex/Chat/Store/Delivery.hs | 8 +- src/Simplex/Chat/Store/Direct.hs | 88 +-- src/Simplex/Chat/Store/Files.hs | 18 +- src/Simplex/Chat/Store/Groups.hs | 414 ++++++------ src/Simplex/Chat/Store/Messages.hs | 130 ++-- src/Simplex/Chat/Store/Profiles.hs | 18 +- src/Simplex/Chat/Store/Shared.hs | 36 +- src/Simplex/Chat/Types.hs | 4 + tests/ChatTests/Utils.hs | 6 +- 19 files changed, 977 insertions(+), 961 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 577cc99752..63e1a0ff69 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -204,7 +204,7 @@ linkCheckThread_ opts env@ServiceState {eventQ} threadDelay $ linkCheckInterval opts * 1000000 u <- readTVarIO $ currentUser cc forM_ u $ \user -> - withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db user) >>= \case + withDB' "linkCheckThread" cc (\db -> getAllGroupRegs_ db (storeCxt cc) user) >>= \case Left e -> logError $ "linkCheckThread error: " <> T.pack e Right grs -> forM_ grs $ \(gInfo, gr) -> unless (groupRemoved $ groupRegStatus gr) $ @@ -462,7 +462,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember) getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of - Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId + Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (storeCxt cc) user gId mId Nothing -> pure $ Left "no owner member in group registration" deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () @@ -556,7 +556,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) -> case dbOwnerMemberId gr of Just ownerGMId -> - withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case Right ownerMember | let GroupMember {memberRole = role} = ownerMember, role >= GROwner -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` g') @@ -813,7 +813,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> False checkValidOwner dbOwnerMemberId owners onValid = case dbOwnerMemberId of Just ownerGMId -> - withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (vr cc) user groupId ownerGMId) >>= \case + withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case Right GroupMember {memberId, memberPubKey} | any (\GroupLinkOwner {memberId = mId, memberKey} -> memberId == mId && memberPubKey == Just memberKey) owners -> onValid _ -> setGroupStatus logError st env cc groupId GRSSuspendedBadRoles $ \gr' -> @@ -985,7 +985,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName addGroupReg notifyAdminUsers st cc ct gInfo GRSProposed $ \_ -> pure () sendChatCmd cc (APIConnectPreparedGroup gId False (Just ownerContact) Nothing) >>= \case Right CRStartedConnectionToGroup {groupInfo = gInfo'} -> - withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user gInfo' mId) >>= \case + withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (storeCxt cc) user gInfo' mId) >>= \case Right ownerMember -> void $ setGroupRegOwner cc gId ownerMember Left e -> do @@ -998,7 +998,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deReregistration ct g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} profileChanged LinkOwnerSig {ownerId = Just (B64UrlByteString oIdBytes)} = do let mId = MemberId oIdBytes gt = maybe "group" groupTypeStr' pg_ - withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (vr cc) user g mId) >>= \case + withDB "getGroupMemberByMemberId" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (storeCxt cc) user g mId) >>= \case Right ownerMember@GroupMember {memberRole = role, memberStatus} -> if | role >= GROwner && memberStatus /= GSMemUnknown -> @@ -1451,7 +1451,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] getOwnersInfo gs = fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db -> - mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs + mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (storeCxt cc) user $ dbContactId gr) gs sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO () sendGroupsInfo ct ciId isAdmin (gs, n) = do @@ -1519,7 +1519,7 @@ updateGroupListingFiles cc u dir = Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact) -getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId +getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (storeCxt cc) user ctId getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink) getGroupLink' cc user gInfo = diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index b5f7220724..4036bd8cf1 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -85,7 +85,6 @@ import Data.Time.Clock.System (systemEpochDay) import Directory.Search import Directory.Util import Simplex.Chat.Controller -import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Store import Simplex.Chat.Store.Groups @@ -315,28 +314,28 @@ getGroupReg_ db gId = getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) getGroupAndReg cc user@User {userId, userContactId} gId = withDB "getGroupAndReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show gId ++ " not found") $ + ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = withDB "getUserGroupReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show ugrId ++ " not found") $ + ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) getUserGroupRegs cc user@User {userId, userContactId} ctId = withDB' "getUserGroupRegs" cc $ \db -> - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) -getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (vr cc) user +getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user -getAllListedGroups_ :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] -getAllListedGroups_ db vr' user@User {userId, userContactId} = +getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] +getAllListedGroups_ db cxt user@User {userId, userContactId} = DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg vr' user) + >>= mapM (withGroupLink . toGroupInfoReg cxt user) where withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) @@ -382,7 +381,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - groups = (map (toGroupInfoReg (vr cc) user) <$>) + groups = (map (toGroupInfoReg (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -395,22 +394,22 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa ) |] -getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)] -getAllGroupRegs_ db user@User {userId, userContactId} = - map (toGroupInfoReg supportedChatVRange user) +getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)] +getAllGroupRegs_ db cxt user@User {userId, userContactId} = + map (toGroupInfoReg cxt user) <$> DB.query db groupReqQuery (userId, userContactId) getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = withDB' "getDuplicateGroupRegs" cc $ \db -> - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listLastGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do gs <- - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" pure (gs, n) @@ -419,14 +418,14 @@ listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(Group listPendingGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do gs <- - map (toGroupInfoReg (vr cc) user) + map (toGroupInfoReg (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) = - (toGroupInfo vr' userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg cxt User {userContactId} (groupRow :. grRow) = + (toGroupInfo cxt userContactId [] groupRow, rowToGroupReg grRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index aa101d7bf7..d501fbd5c3 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -18,10 +18,9 @@ import Directory.Listing import Directory.Options import Directory.Store import Simplex.Chat (createChatDatabase) -import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..), mkStoreCxt) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB -import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store.Groups (getHostMember) import Simplex.Chat.Store.Profiles (getUsers) import Simplex.Chat.Store.Shared (getGroupInfo) @@ -62,7 +61,7 @@ checkDirectoryLog opts cfg = runDirectoryMigrations opts cfg st gs <- readDirectoryLogData logFile withActiveUser st $ \user -> withTransaction st $ \db -> do - mapM_ (verifyGroupRegistration db user) gs + mapM_ (verifyGroupRegistration (mkStoreCxt cfg) db user) gs putStrLn $ show (length gs) <> " group registrations OK" importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO () @@ -73,7 +72,7 @@ importDirectoryLogToDB opts cfg = do ctRegs <- TM.emptyIO withActiveUser st $ \user -> withTransaction st $ \db -> do forM_ gs $ \gr -> - whenM (verifyGroupRegistration db user gr) $ do + whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $ do putStrLn $ "importing group " <> show (dbGroupId gr) insertGroupReg db =<< fixUserGroupRegId ctRegs gr renamePath logFile (logFile ++ ".bak") @@ -101,28 +100,28 @@ exportDBToDirectoryLog opts cfg = runDirectoryMigrations opts cfg st withActiveUser st $ \user -> do gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do - gs <- getAllGroupRegs_ db user + gs <- getAllGroupRegs_ db (mkStoreCxt cfg) user forM_ gs $ \(_, gr) -> - whenM (verifyGroupRegistration db user gr) $ + whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $ B.hPutStrLn h $ strEncode $ GRCreate gr pure gs putStrLn $ show (length gs) <> " group registrations exported" saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO () -saveGroupListingFiles opts _cfg = case webFolder opts of +saveGroupListingFiles opts cfg = case webFolder opts of Nothing -> exit "use --web-folder to generate listings" Just dir -> withChatStore opts $ \st -> withActiveUser st $ \user -> withTransaction st $ \db -> - getAllListedGroups_ db supportedChatVRange user >>= generateListing dir + getAllListedGroups_ db (mkStoreCxt cfg) user >>= generateListing dir -verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO Bool -verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = - runExceptT (getGroupInfo db supportedChatVRange user gId) >>= \case +verifyGroupRegistration :: StoreCxt -> DB.Connection -> User -> GroupReg -> IO Bool +verifyGroupRegistration cxt db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = + runExceptT (getGroupInfo db cxt user gId) >>= \case Left e -> False <$ putStrLn ("Error: loading group " <> show gId <> " (skipping): " <> show e) Right GroupInfo {localDisplayName} -> do let groupRef = show gId <> " " <> T.unpack localDisplayName - runExceptT (getHostMember db supportedChatVRange user gId) >>= \case + runExceptT (getHostMember db cxt user gId) >>= \case Left e -> False <$ putStrLn ("Error: loading host member of group " <> groupRef <> " (skipping): " <> show e) Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> case dbOwnerMemberId of Nothing -> True <$ putStrLn ("Warning: group " <> groupRef <> " has no owner member ID, host member ID is " <> show mId' <> ", registration status: " <> B.unpack (strEncode groupRegStatus)) diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/apps/simplex-directory-service/src/Directory/Util.hs index a4b79a1bef..52d376a945 100644 --- a/apps/simplex-directory-service/src/Directory/Util.hs +++ b/apps/simplex-directory-service/src/Directory/Util.hs @@ -15,9 +15,9 @@ import Simplex.Messaging.Agent.Store.Common (withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (catchAll) -vr :: ChatController -> VersionRangeChat -vr ChatController {config = ChatConfig {chatVRange}} = chatVRange -{-# INLINE vr #-} +storeCxt :: ChatController -> StoreCxt +storeCxt ChatController {config} = mkStoreCxt config +{-# INLINE storeCxt #-} withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fe5b67f041..c92c1f9e09 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -169,6 +169,12 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +-- | Builds the read-only context threaded through store functions from chat config. +-- The single construction point, so new store-wide config (e.g. server keys) is added in one place. +mkStoreCxt :: ChatConfig -> StoreCxt +mkStoreCxt ChatConfig {chatVRange} = StoreCxt chatVRange +{-# INLINE mkStoreCxt #-} + data RandomAgentServers = RandomAgentServers { smpServers :: NonEmpty (ServerCfg 'PSMP), xftpServers :: NonEmpty (ServerCfg 'PXFTP) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 20e9d6a0fb..f35a9ef177 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -327,8 +327,8 @@ execChatCommand rh s retryNum = execChatCommand' :: ChatCommand -> Int -> CM' (Either ChatError ChatResponse) execChatCommand' cmd retryNum = handleCommandError $ do - vr <- chatVersionRange - processChatCommand vr (NRMInteractive' retryNum) cmd + cxt <- chatStoreCxt + processChatCommand cxt (NRMInteractive' retryNum) cmd execRemoteCommand :: RemoteHostId -> ChatCommand -> ByteString -> Int -> CM' (Either ChatError ChatResponse) execRemoteCommand rhId cmd s retryNum = handleCommandError $ getRemoteHostClient rhId >>= \rh -> processRemoteCommand rhId rh cmd s retryNum @@ -345,8 +345,8 @@ parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone -processChatCommand :: VersionRangeChat -> NetworkRequestMode -> ChatCommand -> CM ChatResponse -processChatCommand vr nm = \case +processChatCommand :: StoreCxt -> NetworkRequestMode -> ChatCommand -> CM ChatResponse +processChatCommand cxt nm = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay} -> do forM_ profile $ \Profile {displayName} -> checkValidName displayName @@ -411,26 +411,26 @@ processChatCommand vr nm = \case SetActiveUser uName viewPwd_ -> do tryAllErrors (withFastStore (`getUserIdByName` uName)) >>= \case Left _ -> throwChatError CEUserUnknown - Right userId -> processChatCommand vr nm $ APISetActiveUser userId viewPwd_ + Right userId -> processChatCommand cxt nm $ APISetActiveUser userId viewPwd_ SetAllContactReceipts onOff -> withUser $ \_ -> withFastStore' (`updateAllContactReceipts` onOff) >> ok_ APISetUserContactReceipts userId' settings -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' Nothing withFastStore' $ \db -> updateUserContactReceipts db user' settings ok user - SetUserContactReceipts settings -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserContactReceipts userId settings + SetUserContactReceipts settings -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserContactReceipts userId settings APISetUserGroupReceipts userId' settings -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' Nothing withFastStore' $ \db -> updateUserGroupReceipts db user' settings ok user - SetUserGroupReceipts settings -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserGroupReceipts userId settings + SetUserGroupReceipts settings -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserGroupReceipts userId settings APISetUserAutoAcceptMemberContacts userId' onOff -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' Nothing withFastStore' $ \db -> updateUserAutoAcceptMemberContacts db user' onOff ok user - SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand vr nm $ APISetUserAutoAcceptMemberContacts userId onOff + SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptMemberContacts userId onOff APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of @@ -456,10 +456,10 @@ processChatCommand vr nm = \case setUserPrivacy user user' {viewPwdHash = Nothing, showNtfs = True} APIMuteUser userId' -> setUserNotifications userId' False APIUnmuteUser userId' -> setUserNotifications userId' True - HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand vr nm $ APIHideUser userId viewPwd - UnhideUser viewPwd -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUnhideUser userId viewPwd - MuteUser -> withUser $ \User {userId} -> processChatCommand vr nm $ APIMuteUser userId - UnmuteUser -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUnmuteUser userId + HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIHideUser userId viewPwd + UnhideUser viewPwd -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIUnhideUser userId viewPwd + MuteUser -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIMuteUser userId + UnmuteUser -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIUnmuteUser userId APIDeleteUser userId' delSMPQueues viewPwd_ -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' viewPwd_ @@ -529,7 +529,7 @@ processChatCommand vr nm = \case ExportArchive -> do ts <- liftIO getCurrentTime let filePath = "simplex-chat." <> formatTime defaultTimeLocale "%FT%H%M%SZ" ts <> ".zip" - processChatCommand vr nm $ APIExportArchive $ ArchiveConfig filePath Nothing Nothing + processChatCommand cxt nm $ APIExportArchive $ ArchiveConfig filePath Nothing Nothing APIImportArchive cfg -> checkChatStopped $ do fileErrs <- lift $ importArchive cfg setStoreChanged @@ -558,16 +558,16 @@ processChatCommand vr nm = \case tags <- withFastStore' (`getUserChatTags` user) pure $ CRChatTags user tags APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do - (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) + (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user pendingConnections pagination query) unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRApiChats user previews APIGetChat (ChatRef cType cId scope_) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do - (directChat, navInfo) <- withFastStore (\db -> getDirectChat db vr user cId contentFilter pagination search) + (directChat, navInfo) <- withFastStore (\db -> getDirectChat db cxt user cId contentFilter pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do - (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search) + (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db cxt user cId scope_ contentFilter pagination search) groupChat' <- checkSupportChatAttention user groupChat pure $ CRApiChat user (AChat SCTGroup groupChat') navInfo CTLocal -> do @@ -583,7 +583,7 @@ processChatCommand vr nm = \case case correctedMemAttention (groupMemberId' scopeMem) suppChat chatItems of Just newMemAttention -> do (gInfo', scopeMem') <- - withFastStore' $ \db -> setSupportChatMemberAttention db vr user gInfo scopeMem newMemAttention + withFastStore' $ \db -> setSupportChatMemberAttention db cxt user gInfo scopeMem newMemAttention pure (groupChat {chatInfo = GroupChat gInfo' (Just $ GCSIMemberSupport (Just scopeMem'))} :: Chat 'CTGroup) Nothing -> pure groupChat _ -> pure groupChat @@ -600,11 +600,11 @@ processChatCommand vr nm = \case APIGetChatContentTypes chatRef -> withUser $ \user -> CRChatContentTypes <$> withStore (\db -> getChatContentTypes db user chatRef) APIGetChatItems pagination search -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user pagination search pure $ CRChatItems user Nothing chatItems APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do (aci@(AChatItem cType dir _ ci), versions) <- withFastStore $ \db -> - (,) <$> getAChatItem db vr user chatRef itemId <*> liftIO (getChatItemVersions db itemId) + (,) <$> getAChatItem db cxt user chatRef itemId <*> liftIO (getChatItemVersions db itemId) let itemVersions = if null versions then maybeToList $ mkItemVersion ci else versions memberDeliveryStatuses <- case (cType, dir) of (SCTGroup, SMDSnd) -> L.nonEmpty <$> withFastStore' (`getGroupSndStatuses` itemId) @@ -615,10 +615,10 @@ processChatCommand vr nm = \case getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of Just (CIFFContact _ _ (Just ctId) (Just fwdItemId)) -> - Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTDirect ctId Nothing) fwdItemId) + Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTDirect ctId Nothing) fwdItemId) Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> -- TODO [knocking] getAChatItem doesn't differentiate how to read based on scope - it should, instead of using group filter - Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId Nothing) fwdItemId) + Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTGroup gId Nothing) fwdItemId) _ -> pure Nothing APISendMessages sendRef live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case sendRef of SRDirect chatId -> do @@ -631,7 +631,7 @@ processChatCommand vr nm = \case Nothing -> pure () withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do - g <- getGroupInfo db vr user chatId + g <- getGroupInfo db cxt user chatId (g,) <$> mapM (composedMessageReqMentions db user g) cms sendGroupContentMessages user gInfo gsScope asGroup live itemTTL cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do @@ -659,18 +659,18 @@ processChatCommand vr nm = \case createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage - processChatCommand vr nm $ APIReportMessage gId reportedItemId reportReason "" + processChatCommand cxt nm $ APIReportMessage gId reportedItemId reportReason "" APIUpdateChatItem (ChatRef cType chatId scope) itemId live (UpdatedMessage mc mentions) -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do unless (null mentions) $ throwCmdError "mentions are not supported in this chat" - ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId + ct@Contact {contactId} <- withFastStore $ \db -> getContact db cxt user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withFastStore $ \db -> getDirectCIWithReactions db user ct itemId case cci of @@ -694,7 +694,7 @@ processChatCommand vr nm = \case _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> withGroupLock "updateChatItem" chatId $ do - gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId + gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor let (_, ft_) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ @@ -706,8 +706,8 @@ processChatCommand vr nm = \case CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion let changed = mc /= oldMC if changed || fromMaybe False itemLive then do @@ -763,7 +763,7 @@ processChatCommand vr nm = \case CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds -- TODO [knocking] check scope for all items? - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope deletions <- case mode of CIDMInternal | publicGroupEditor gInfo (membership gInfo) -> throwChatError CEInvalidChatItemDelete @@ -771,7 +771,7 @@ processChatCommand vr nm = \case CIDMInternalMark -> do markGroupCIsDeleted user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items @@ -780,7 +780,7 @@ processChatCommand vr nm = \case delGroupChatItems user gInfo chatScopeInfo items False CIDMHistory -> do unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion let msgIds = itemsMsgIds items events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) True) msgIds mapM_ (sendGroupMessages user gInfo Nothing False recipients) events @@ -808,12 +808,12 @@ processChatCommand vr nm = \case APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do (gInfo, items) <- getCommandGroupChatItems user gId itemIds -- TODO [knocking] check scope is Nothing for all items? (prohibit moderation in support chats?) - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo let recipients = filter memberCurrent ms deletions <- delGroupChatItemsForMembers user gInfo Nothing recipients items pure $ CRChatItemsDeleted user deletions True False APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do - g <- getGroupInfo db vr user gId + g <- getGroupInfo db cxt user gId deleteTs <- liftIO getCurrentTime ciIds <- liftIO $ markReceivedGroupReportsDeleted db user g deleteTs pure $ CRGroupChatItemsDeleted user g ciIds True (Just $ membership g) @@ -827,7 +827,7 @@ processChatCommand vr nm = \case CIDMInternalMark -> markGroupCIsDeleted user gInfo Nothing items Nothing =<< liftIO getCurrentTime CIDMHistory -> throwChatError CEInvalidChatItemDelete CIDMBroadcast -> do - ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + ms <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let recipients = filter memberCurrent ms delGroupChatItemsForMembers user gInfo Nothing recipients items pure $ CRChatItemsDeleted user deletions True False @@ -838,7 +838,7 @@ processChatCommand vr nm = \case APIChatItemReaction (ChatRef cType chatId scope) itemId add reaction -> withUser $ \user -> case cType of CTDirect -> withContactLock "chatItemReaction" chatId $ - withFastStore (\db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId) >>= \case + withFastStore (\db -> (,) <$> getContact db cxt user chatId <*> getDirectChatItem db user chatId itemId) >>= \case (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (featureAllowed SCFReactions forUser ct) $ throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) @@ -859,10 +859,10 @@ processChatCommand vr nm = \case withGroupLock "chatItemReaction" chatId $ do -- TODO [knocking] check chat item scope? (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do - g <- getGroupInfo db vr user chatId + g <- getGroupInfo db cxt user chatId (g,) <$> getGroupCIWithReactions db user g itemId - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user g chatScopeInfo groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user g chatScopeInfo groupKnockingVersion case ci of ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} -> do unless (groupFeatureAllowed SGFReactions g) $ @@ -893,7 +893,7 @@ processChatCommand vr nm = \case APIGetReactionMembers userId groupId itemId reaction -> withUserId userId $ \user -> do memberReactions <- withStore $ \db -> do CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} <- getGroupChatItem db user groupId itemId - liftIO $ getReactionMembers db vr user groupId itemSharedMId reaction + liftIO $ getReactionMembers db cxt user groupId itemSharedMId reaction pure $ CRReactionMembers user memberReactions -- TODO [knocking] forward from scope? APIPlanForwardChatItems (ChatRef fromCType fromChatId _scope) itemIds -> withUser $ \user -> case fromCType of @@ -957,7 +957,7 @@ processChatCommand vr nm = \case case L.nonEmpty cmrs of Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user toChatId sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do @@ -1091,7 +1091,7 @@ processChatCommand vr nm = \case pure $ prefix <> formattedDate <> ext APIShareChatMsgContent (ChatRef CTGroup groupId _) toSendRef -> withUser $ \user -> do GroupInfo {groupProfile = gp@GroupProfile {publicGroup}, membership = GroupMember {memberId, memberRole}, groupKeys} <- - withFastStore $ \db -> getGroupInfo db vr user groupId + withFastStore $ \db -> getGroupInfo db cxt user groupId case publicGroup of Nothing -> throwCmdError "not a public group" Just PublicGroupProfile {groupLink} -> do @@ -1113,11 +1113,11 @@ processChatCommand vr nm = \case shareChatBinding :: User -> SendRef -> CM (Maybe (ChatBinding, ByteString)) shareChatBinding u = \case SRDirect contactId -> do - ct <- withFastStore $ \db -> getContact db vr u contactId + ct <- withFastStore $ \db -> getContact db cxt u contactId forM (contactConn ct) $ \conn -> (CBDirect,) <$> withAgent (`getConnectionRatchetAdHash` aConnId conn) SRGroup toGroupId _ asGroup -> do - GroupInfo {groupProfile = GroupProfile {publicGroup}, membership = m} <- withFastStore $ \db -> getGroupInfo db vr u toGroupId + GroupInfo {groupProfile = GroupProfile {publicGroup}, membership = m} <- withFastStore $ \db -> getGroupInfo db cxt u toGroupId pure $ mkBinding m <$> publicGroup where mkBinding GroupMember {memberId} PublicGroupProfile {publicGroupId = pgId} @@ -1125,7 +1125,7 @@ processChatCommand vr nm = \case | otherwise = (CBGroup, smpEncode (pgId, memberId)) APIShareChatMsgContent _ _ -> throwCmdError "sharing is only supported for public groups" APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user - UserRead -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUserRead userId + UserRead -> withUser $ \User {userId} -> processChatCommand cxt nm $ APIUserRead userId APIChatRead chatRef@(ChatRef cType chatId scope_) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId @@ -1139,7 +1139,7 @@ processChatCommand vr nm = \case CTGroup -> do (user, gInfo) <- withFastStore $ \db -> do user <- getUserByGroupId db chatId - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId pure (user, gInfo) ts <- liftIO getCurrentTime case scope_ of @@ -1151,10 +1151,10 @@ processChatCommand vr nm = \case forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user Just scope -> do - scopeInfo <- getChatScopeInfo vr user scope + scopeInfo <- getChatScopeInfo cxt user scope (gInfo', m', timedItems) <- withFastStore' $ \db -> do timedItems <- getGroupUnreadTimedItems db user chatId (Just scope) - (gInfo', m') <- updateSupportChatItemsRead db vr user gInfo scopeInfo + (gInfo', m') <- updateSupportChatItemsRead db cxt user gInfo scopeInfo timedItems' <- setGroupChatItemsDeleteAt db user chatId timedItems ts pure (gInfo', m', timedItems') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt @@ -1169,7 +1169,7 @@ processChatCommand vr nm = \case CTDirect -> do (user, ct) <- withFastStore $ \db -> do user <- getUserByContactId db chatId - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId pure (user, ct) timedItems <- withFastStore' $ \db -> do timedItems <- updateDirectChatItemsReadList db user chatId itemIds @@ -1179,11 +1179,11 @@ processChatCommand vr nm = \case CTGroup -> do (user, gInfo) <- withFastStore $ \db -> do user <- getUserByGroupId db chatId - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId pure (user, gInfo) - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope (timedItems, gInfo') <- withFastStore $ \db -> do - (timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo chatScopeInfo itemIds + (timedItems, gInfo') <- updateGroupChatItemsReadList db cxt user gInfo chatScopeInfo itemIds timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime pure (timedItems', gInfo') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt @@ -1194,13 +1194,13 @@ processChatCommand vr nm = \case APIChatUnread (ChatRef cType chatId scope) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId liftIO $ updateContactUnreadChat db user ct unreadChat ok user -- TODO [knocking] set support chat as unread? CTGroup | isNothing scope -> do withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId liftIO $ updateGroupUnreadChat db user gInfo unreadChat ok user CTLocal -> do @@ -1211,7 +1211,7 @@ processChatCommand vr nm = \case _ -> throwCmdError "not supported" APIDeleteChat cRef@(ChatRef cType chatId scope) cdm -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withFastStore $ \db -> getContact db vr user chatId + ct <- withFastStore $ \db -> getContact db cxt user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct withContactLock "deleteChat direct" chatId $ case cdm of @@ -1231,17 +1231,17 @@ processChatCommand vr nm = \case ct' <- withFastStore $ \db -> do liftIO $ deleteContactConnections db user ct liftIO $ void $ updateContactStatus db user ct CSDeletedByUser - getContact db vr user chatId + getContact db cxt user chatId pure $ CRContactDeleted user ct' CDMMessages -> do - void $ processChatCommand vr nm $ APIClearChat cRef + void $ processChatCommand cxt nm $ APIClearChat cRef withFastStore' $ \db -> setContactChatDeleted db user ct True pure $ CRContactDeleted user ct {chatDeleted = True} where sendDelDeleteConns ct notify = do let doSendDel = contactReady ct && contactActive ct && notify when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchAllErrors` const (pure ()) - contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db vr userId ct) + contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db cxt userId ct) deleteAgentConnectionsAsync' contactConnIds doSendDel CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withFastStore $ \db -> getPendingContactConnection db userId chatId @@ -1249,7 +1249,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user chatId let isOwner = memberRole' membership == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner @@ -1273,25 +1273,25 @@ processChatCommand vr nm = \case where getRecipients gInfo | useRelays' gInfo = do - relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + relays <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo pure (relays, relays) | otherwise = do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo pure (ms, filter memberCurrentOrPending ms) _ -> throwCmdError "not supported" APIClearChat (ChatRef cType chatId scope) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct <- withFastStore $ \db -> getContact db vr user chatId + ct <- withFastStore $ \db -> getContact db cxt user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct deleteCIFiles user filesInfo withFastStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup | isNothing scope -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user chatId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user chatId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo deleteCIFiles user filesInfo withFastStore' $ \db -> deleteGroupChatItemsMessages db user gInfo - membersToDelete <- withFastStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + membersToDelete <- withFastStore' $ \db -> getGroupMembersForExpiration db cxt user gInfo forM_ membersToDelete $ \m -> withFastStore' $ \db -> deleteGroupMember db user m pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo Nothing) CTLocal -> do @@ -1352,7 +1352,7 @@ processChatCommand vr nm = \case withFastStore $ \db -> do cReq@UserContactRequest {contactId_} <- getContactRequest db user connReqId ct_ <- forM contactId_ $ \contactId -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId deleteContact db user ct pure ct liftIO $ deleteContactRequest db user connReqId @@ -1361,7 +1361,7 @@ processChatCommand vr nm = \case pure $ CRContactRequestRejected user cReq ct_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId assertDirectAllowed user MDSnd ct XCallInv_ if featureAllowed SCFCalls forUser ct then do @@ -1383,7 +1383,7 @@ processChatCommand vr nm = \case else throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFCalls) SendCallInvitation cName callType -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName - processChatCommand vr nm $ APISendCallInvitation contactId callType + processChatCommand cxt nm $ APISendCallInvitation contactId callType APIRejectCall contactId -> -- party accepting call withCurrentCall contactId $ \user ct Call {chatItemId, callState} -> case callState of @@ -1450,23 +1450,23 @@ processChatCommand vr nm = \case _ -> Nothing rcvCallInvitation (contactId, callUUID, callTs, peerCallType, sharedKey) = runExceptT . withFastStore $ \db -> do user <- getUserByContactId db contactId - contact <- getContact db vr user contactId + contact <- getContact db cxt user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callUUID, callTs} APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) APISetContactPrefs contactId prefs' -> withUser $ \user -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId updateContactPrefs user ct prefs' APISetContactAlias contactId localAlias -> withUser $ \user@User {userId} -> do ct' <- withFastStore $ \db -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId liftIO $ updateContactAlias db userId ct localAlias pure $ CRContactAliasUpdated user ct' APISetGroupAlias gId localAlias -> withUser $ \user@User {userId} -> do gInfo' <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user gId + gInfo <- getGroupInfo db cxt user gId liftIO $ updateGroupAlias db userId gInfo localAlias pure $ CRGroupAliasUpdated user gInfo' APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do @@ -1484,23 +1484,23 @@ processChatCommand vr nm = \case APISetChatUIThemes (ChatRef cType chatId scope) uiThemes -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId liftIO $ setContactUIThemes db user ct uiThemes ok user CTGroup | isNothing scope -> do withFastStore $ \db -> do - g <- getGroupInfo db vr user chatId + g <- getGroupInfo db cxt user chatId liftIO $ setGroupUIThemes db user g uiThemes ok user _ -> throwCmdError "not supported" APISetGroupCustomData groupId customData_ -> withUser $ \user -> do withFastStore $ \db -> do - g <- getGroupInfo db vr user groupId + g <- getGroupInfo db cxt user groupId liftIO $ setGroupCustomData db user g customData_ ok user APISetContactCustomData contactId customData_ -> withUser $ \user -> do withFastStore $ \db -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId liftIO $ setContactCustomData db user ct customData_ ok user APIGetNtfToken -> withUser' $ \_ -> crNtfToken <$> withAgent getNtfToken @@ -1521,7 +1521,7 @@ processChatCommand vr nm = \case let agentConnId = AgentConnId ntfConnId mkNtfConn user connEntity = NtfConn {user, agentConnId, agentDbQueueId = ntfDbQueueId, connEntity, expectedMsg_ = expectedMsgInfo <$> nMsgMeta} getUserByAConnId db agentConnId - $>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId) + $>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db cxt user agentConnId) APIGetConnNtfMessages connMsgs -> withUser $ \_ -> do msgs <- lift $ withAgent' (`getConnectionMessages` connMsgs) let ntfMsgs = L.map receivedMsgInfo msgs @@ -1537,7 +1537,7 @@ processChatCommand vr nm = \case [] -> throwCmdError "no servers" _ -> do srvs' <- mapM aUserServer srvs - processChatCommand vr nm $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers + processChatCommand cxt nm $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers where aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of @@ -1546,7 +1546,7 @@ processChatCommand vr nm = \case APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a nm (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> - processChatCommand vr nm $ APITestProtoServer userId srv + processChatCommand cxt nm $ APITestProtoServer userId srv APITestChatRelay userId address -> withUserId userId $ \user -> do let failAt step e = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step e) r <- tryAllErrors $ getShortLinkConnReq nm user address @@ -1566,7 +1566,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff conn@Connection {connId = testCId} <- withFastStore $ \db -> - createRelayTestConnection db vr user connId ConnPrepared chatV subMode + createRelayTestConnection db cxt user connId ConnPrepared chatV subMode challenge <- drgRandomBytes 32 testVar <- newEmptyTMVarIO let acId = aConnId conn @@ -1586,9 +1586,9 @@ processChatCommand vr nm = \case Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) TestChatRelay address -> withUser $ \User {userId} -> - processChatCommand vr nm $ APITestChatRelay userId address + processChatCommand cxt nm $ APITestChatRelay userId address APIAllowRelayGroup groupId -> withUser $ \user -> do - gInfo' <- withStore $ \db -> allowRelayGroup db vr user groupId + gInfo' <- withStore $ \db -> allowRelayGroup db cxt user groupId pure $ CRRelayGroupAllowed user gInfo' GetUserChatRelays -> withUser $ \user -> do srvs <- withFastStore (`getUserServers` user) @@ -1601,7 +1601,7 @@ processChatCommand vr nm = \case [] -> throwCmdError "no relays" _ -> do let relays' = map aUserRelay relays - processChatCommand vr nm $ APISetUserServers userId $ L.map (updatedRelays relays') userServers + processChatCommand cxt nm $ APISetUserServers userId $ L.map (updatedRelays relays') userServers where aUserRelay :: CLINewRelay -> AUserChatRelay aUserRelay CLINewRelay {address, name} = AUCR SDBNew $ newChatRelay (mkRelayProfile name Nothing) [""] address @@ -1630,7 +1630,7 @@ processChatCommand vr nm = \case SetServerOperators operatorsRoles -> do ops <- serverOperators <$> withFastStore getServerOperators ops' <- mapM (updateOp ops) operatorsRoles - processChatCommand vr nm $ APISetServerOperators ops' + processChatCommand cxt nm $ APISetServerOperators ops' where updateOp :: [ServerOperator] -> ServerOperatorRoles -> CM ServerOperator updateOp ops r = @@ -1695,14 +1695,14 @@ processChatCommand vr nm = \case expireChat user globalTTL = do currentTs <- liftIO getCurrentTime case cType of - CTDirect -> expireContactChatItems user vr globalTTL chatId + CTDirect -> expireContactChatItems user cxt globalTTL chatId CTGroup | isNothing scope -> let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs - in expireGroupChatItems user vr globalTTL createdAtCutoff chatId + in expireGroupChatItems user cxt globalTTL createdAtCutoff chatId _ -> throwCmdError "not supported" SetChatTTL chatName newTTL -> withUser' $ \user@User {userId} -> do chatRef <- getChatRef user chatName - processChatCommand vr nm $ APISetChatTTL userId chatRef newTTL + processChatCommand cxt nm $ APISetChatTTL userId chatRef newTTL GetChatTTL chatName -> withUser' $ \user -> do -- TODO [knocking] support scope in CLI apis ChatRef cType chatId _ <- getChatRef user chatName @@ -1722,18 +1722,18 @@ processChatCommand vr nm = \case lift $ setChatItemsExpiration user newTTL ttlCount ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do - processChatCommand vr nm $ APISetChatItemTTL userId newTTL_ + processChatCommand cxt nm $ APISetChatItemTTL userId newTTL_ APIGetChatItemTTL userId -> withUserId' userId $ \user -> do ttl <- withFastStore' (`getChatItemTTL` user) pure $ CRChatItemTTL user (Just ttl) GetChatItemTTL -> withUser' $ \User {userId} -> do - processChatCommand vr nm $ APIGetChatItemTTL userId + processChatCommand cxt nm $ APIGetChatItemTTL userId APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_ APIGetNetworkConfig -> withUser' $ \_ -> CRNetworkConfig <$> lift getNetworkConfig SetNetworkConfig simpleNetCfg -> do cfg <- (`updateNetworkConfig` simpleNetCfg) <$> lift getNetworkConfig - void . processChatCommand vr nm $ APISetNetworkConfig cfg + void . processChatCommand cxt nm $ APISetNetworkConfig cfg pure $ CRNetworkConfig cfg APISetNetworkInfo info -> lift (withAgent' (`setUserNetworkInfo` info)) >> ok_ ReconnectAllServers -> withUser' $ \_ -> lift (withAgent' reconnectAllServers) >> ok_ @@ -1743,7 +1743,7 @@ processChatCommand vr nm = \case APISetChatSettings (ChatRef cType chatId scope) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withFastStore $ \db -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct forM_ (contactConnId ct) $ \connId -> @@ -1751,7 +1751,7 @@ processChatCommand vr nm = \case ok user CTGroup | isNothing scope -> do ms <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId ms <- liftIO $ getMembers db gInfo liftIO $ updateGroupSettings db user chatId chatSettings pure ms @@ -1760,19 +1760,19 @@ processChatCommand vr nm = \case ok user where getMembers db gInfo - | useRelays' gInfo = getGroupRelayMembers db vr user gInfo - | otherwise = getGroupMembers db vr user gInfo + | useRelays' gInfo = getGroupRelayMembers db cxt user gInfo + | otherwise = getGroupMembers db cxt user gInfo _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withFastStore $ \db -> do liftIO $ updateGroupMemberSettings db user gId gMemberId settings - getGroupMember db vr user gId gMemberId + getGroupMember db cxt user gId gMemberId let ntfOn = not (memberBlocked m) toggleNtf m ntfOn ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId incognitoProfile <- case activeConn of Nothing -> pure Nothing Just Connection {customUserProfileId} -> @@ -1780,14 +1780,14 @@ processChatCommand vr nm = \case connectionStats <- mapM (withAgent . flip getConnectionServers) (contactConnId ct) pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile) APIContactQueueInfo contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn -> getConnQueueInfo user conn Nothing -> throwChatError $ CEContactNotActive ct APIGroupInfo gId -> withUser $ \user -> - CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db vr user gId) + CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db cxt user gId) APIGetUpdatedGroupLinkData groupId -> withUser $ \user -> do - gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} <- withFastStore $ \db -> getGroupInfo db cxt user groupId case p of GroupProfile {publicGroup = Just PublicGroupProfile {groupLink = sLnk}} | useRelays' gInfo -> do (_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks})) <- getShortLinkConnReq' nm user sLnk @@ -1801,44 +1801,44 @@ processChatCommand vr nm = \case pure $ CRGroupInfo user gInfo' _ -> throwCmdError "group link data not available" APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) pure $ CRGroupMemberInfo user g m connectionStats APIGroupMemberQueueInfo gId gMemberId -> withUser $ \user -> do - GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db vr user gId gMemberId + GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db cxt user gId gMemberId case activeConn of Just conn -> getConnQueueInfo user conn Nothing -> throwChatError CEGroupMemberNotActive APISwitchContact contactId -> withUser $ \user -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> switchConnectionAsync a "" connId pure $ CRContactSwitchStarted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APISwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent (\a -> switchConnectionAsync a "" connId) pure $ CRGroupMemberSwitchStarted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APIAbortSwitchContact contactId -> withUser $ \user -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConnId ct of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRContactSwitchAborted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APIAbortSwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive APISyncContactRatchet contactId force -> withUser $ \user -> withContactLock "syncContactRatchet" contactId $ do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConn ct of Just conn@Connection {pqSupport} -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (aConnId conn) pqSupport force @@ -1846,7 +1846,7 @@ processChatCommand vr nm = \case pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withGroupLock "syncGroupMemberRatchet" gId $ do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force @@ -1855,7 +1855,7 @@ processChatCommand vr nm = \case pure $ CRGroupMemberRatchetSyncStarted user g' m' cStats _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1869,7 +1869,7 @@ processChatCommand vr nm = \case pure $ CRContactCode user ct' code Nothing -> throwChatError $ CEContactNotActive ct APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do - (g, m@GroupMember {activeConn}) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m@GroupMember {activeConn}) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1883,24 +1883,24 @@ processChatCommand vr nm = \case pure $ CRGroupMemberCode user g m' code _ -> throwChatError CEGroupMemberNotActive APIVerifyContact contactId code -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn -> verifyConnectionCode user conn code Nothing -> throwChatError $ CEContactNotActive ct APIVerifyGroupMember gId gMemberId code -> withUser $ \user -> do - GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db vr user gId gMemberId + GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db cxt user gId gMemberId case activeConn of Just conn -> verifyConnectionCode user conn code _ -> throwChatError CEGroupMemberNotActive APIEnableContact contactId -> withUser $ \user -> do - ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {activeConn} <- withFastStore $ \db -> getContact db cxt user contactId case activeConn of Just conn -> do withFastStore' $ \db -> setAuthErrCounter db user conn 0 ok user Nothing -> throwChatError $ CEContactNotActive ct APIEnableGroupMember gId gMemberId -> withUser $ \user -> do - GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db vr user gId gMemberId + GroupMember {activeConn} <- withFastStore $ \db -> getGroupMember db cxt user gId gMemberId case activeConn of Just conn -> do withFastStore' $ \db -> setAuthErrCounter db user conn 0 @@ -1910,16 +1910,16 @@ processChatCommand vr nm = \case SetSendReceipts cName rcptsOn_ -> updateChatSettings cName (\cs -> cs {sendRcpts = rcptsOn_}) SetShowMemberMessages gName mName showMessages -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId - m <- withFastStore $ \db -> getGroupMember db vr user gId mId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId + m <- withFastStore $ \db -> getGroupMember db cxt user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo when (membershipRole >= GRModerator) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} - processChatCommand vr nm $ APISetMemberSettings gId mId settings + processChatCommand cxt nm $ APISetMemberSettings gId mId settings ContactInfo cName -> withContactName cName APIContactInfo ShowGroupInfo gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIGroupInfo groupId + processChatCommand cxt nm $ APIGroupInfo groupId GroupMemberInfo gName mName -> withMemberName gName mName APIGroupMemberInfo ContactQueueInfo cName -> withContactName cName APIContactQueueInfo GroupMemberQueueInfo gName mName -> withMemberName gName mName APIGroupMemberQueueInfo @@ -1950,7 +1950,7 @@ processChatCommand vr nm = \case conn <- withFastStore' $ \db -> createDirectConnection db user connId ccLink' Nothing ConnNew incognitoProfile subMode initialChatVersion PQSupportOn pure $ CRInvitation user ccLink' conn AddContact incognito -> withUser $ \User {userId} -> - processChatCommand vr nm $ APIAddContact userId incognito + processChatCommand cxt nm $ APIAddContact userId incognito APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do conn <- withFastStore $ \db -> getPendingContactConnection db userId connId let PendingContactConnection {pccConnStatus, customUserProfileId} = conn @@ -2008,7 +2008,7 @@ processChatCommand vr nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember @@ -2021,7 +2021,7 @@ processChatCommand vr nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do - ct <- withStore $ \db -> createPreparedContact db vr user profile accLink welcomeSharedMsgId + ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing @@ -2040,7 +2040,7 @@ processChatCommand vr nm = \case let useRelays = not direct subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing @@ -2051,40 +2051,40 @@ processChatCommand vr nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat APIChangePreparedContactUser contactId newUserId -> withUser $ \user -> do - ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId when (isNothing preparedContact) $ throwCmdError "contact doesn't have link to connect" when (isJust $ contactConn ct) $ throwCmdError "contact already has connection" newUser <- privateGetUser newUserId - ct' <- withFastStore $ \db -> updatePreparedContactUser db vr user ct newUser + ct' <- withFastStore $ \db -> updatePreparedContactUser db cxt user ct newUser -- create changed feature items (new user may have different preferences) lift $ createContactChangedFeatureItems user ct ct' pure $ CRContactUserChanged user ct newUser ct' APIChangePreparedGroupUser groupId newUserId -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId when (isNothing $ preparedGroup gInfo) $ throwCmdError "group doesn't have link to connect" hostMember_ <- if useRelays' gInfo then pure Nothing else do - hostMember <- withFastStore $ \db -> getHostMember db vr user groupId + hostMember <- withFastStore $ \db -> getHostMember db cxt user groupId when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" pure $ Just hostMember newUser <- privateGetUser newUserId - gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db vr user gInfo hostMember_ newUser + gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db cxt user gInfo hostMember_ newUser pure $ CRGroupUserChanged user gInfo newUser gInfo' APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do - ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId case preparedContact of Nothing -> throwCmdError "contact doesn't have link to connect" Just PreparedContact {connLinkToConnect = ACCL SCMInvitation ccLink} -> do (_, customUserProfile) <- connectViaInvitation user incognito ccLink (Just contactId) `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e -- get updated contact with connection - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId -- create changed feature items (connecting incognito sends default preferences, instead of user preferences) lift . when incognito $ createContactChangedFeatureItems user ct ct' forM_ msgContent_ $ \mc -> do @@ -2103,13 +2103,13 @@ processChatCommand vr nm = \case r <- connectViaContact user (Just $ PCEContact ct) incognito ccLink welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e case r of CVRSentInvitation _conn customUserProfile -> do -- get updated contact with connection - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId -- create changed feature items (connecting incognito sends default preferences, instead of user preferences) lift . when incognito $ createContactChangedFeatureItems user ct ct' forM_ msg_ $ \(sharedMsgId, mc) -> do @@ -2118,7 +2118,7 @@ processChatCommand vr nm = \case pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' APIConnectPreparedGroup {groupId, incognito, ownerContact, msgContent_} -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId case gInfo of GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do @@ -2141,14 +2141,14 @@ processChatCommand vr nm = \case gVar <- asks random (_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar gInfo' <- withFastStore $ \db -> do - gInfo' <- updatePreparedRelayedGroup db vr user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_ + gInfo' <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_ -- Pre-emptively create owner members with trusted keys from link data forM_ owners $ \OwnerAuth {ownerId, ownerKey} -> do let ctId_ = case ownerContact of Just GroupOwnerContact {contactId, memberId} | memberId == MemberId ownerId -> Just contactId _ -> Nothing - void $ createLinkOwnerMember db vr user gInfo' ctId_ (MemberId ownerId) ownerKey + void $ createLinkOwnerMember db cxt user gInfo' ctId_ (MemberId ownerId) ownerKey pure gInfo' rs <- withGroupLock "connectPreparedGroup" groupId $ mapConcurrently (connectToRelay user gInfo') relays @@ -2166,7 +2166,7 @@ processChatCommand vr nm = \case else do gInfo'' <- withFastStore $ \db -> do liftIO $ setPreparedGroupStartedConnection db groupId - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId -- Async retry failed relays with temporary errors let retryable = [(l, m) | r@(l, m, _) <- failed, isTempErr r] void $ mapConcurrently (uncurry $ retryRelayConnectionAsync gInfo') retryable @@ -2186,7 +2186,7 @@ processChatCommand vr nm = \case newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink withStore' $ \db -> createRelayMemberConnectionAsync db user gInfo' relayMember relayLink newConnIds subMode GroupInfo {preparedGroup = Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId}} -> do - hostMember <- withFastStore $ \db -> getHostMember db vr user groupId + hostMember <- withFastStore $ \db -> getHostMember db cxt user groupId msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of Just smId -> pure (smId, mc) Nothing -> do @@ -2196,7 +2196,7 @@ processChatCommand vr nm = \case r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated group info, in case connection was started (connLinkPreparedConnection) - in UI it would lock ability to change -- user or incognito profile for group or business chat, in case server received request while client got network error - gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo' <- withFastStore $ \db -> getGroupInfo db cxt user groupId toView $ CEvtChatInfoUpdated user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) throwError e case r of @@ -2204,7 +2204,7 @@ processChatCommand vr nm = \case -- get updated group info (connLinkStartedConnection and incognito membership) gInfo' <- withFastStore $ \db -> do liftIO $ setPreparedGroupStartedConnection db groupId - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId forM_ msg_ $ \(sharedMsgId, mc) -> do ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing toView $ CEvtNewChatItems user [ci] @@ -2230,7 +2230,7 @@ processChatCommand vr nm = \case connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do - ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db vr user contactId + ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db cxt user contactId ccLink <- case contactLink of Just (CLFull cReq) -> pure $ CCLink cReq Nothing Just (CLShort sLnk) -> do @@ -2240,7 +2240,7 @@ processChatCommand vr nm = \case connectContactViaAddress user incognito ct ccLink `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change incognito choice -- on next connection attempt, in case server received request while client got network error - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e ConnectSimplex incognito -> withUser $ \user -> do @@ -2249,9 +2249,9 @@ processChatCommand vr nm = \case DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing APIListContacts userId -> withUserId userId $ \user -> - CRContactsList user <$> withFastStore' (\db -> getUserContacts db vr user) + CRContactsList user <$> withFastStore' (\db -> getUserContacts db cxt user) ListContacts -> withUser $ \User {userId} -> - processChatCommand vr nm $ APIListContacts userId + processChatCommand cxt nm $ APIListContacts userId APICreateMyAddress userId -> withUserId userId $ \user@User {userChatRelay} -> do withFastStore' (\db -> runExceptT $ getUserAddress db user) >>= \case Left SEUserContactLinkNotFound -> pure () @@ -2270,9 +2270,9 @@ processChatCommand vr nm = \case withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode pure $ CRUserContactLinkCreated user ccLink'' CreateMyAddress -> withUser $ \User {userId} -> - processChatCommand vr nm $ APICreateMyAddress userId + processChatCommand cxt nm $ APICreateMyAddress userId APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do - conn <- withFastStore $ \db -> getUserAddressConnection db vr user + conn <- withFastStore $ \db -> getUserAddressConnection db cxt user withChatLock "deleteMyAddress" $ do deleteAgentConnectionAsync $ aConnId conn withFastStore' (`deleteUserAddress` user) @@ -2283,11 +2283,11 @@ processChatCommand vr nm = \case _ -> user pure $ CRUserContactLinkDeleted user' DeleteMyAddress -> withUser $ \User {userId} -> - processChatCommand vr nm $ APIDeleteMyAddress userId + processChatCommand cxt nm $ APIDeleteMyAddress userId APIShowMyAddress userId -> withUserId' userId $ \user -> CRUserContactLink user <$> withFastStore (`getUserAddress` user) ShowMyAddress -> withUser' $ \User {userId} -> - processChatCommand vr nm $ APIShowMyAddress userId + processChatCommand cxt nm $ APIShowMyAddress userId APIAddMyAddressShortLink userId -> withUserId' userId $ \user -> CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData user) APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do @@ -2299,7 +2299,7 @@ processChatCommand vr nm = \case let p' = (fromLocalProfile p :: Profile) {contactLink = Just $ profileContactLink ucl} updateProfile_ user p' True $ withFastStore' $ \db -> setUserProfileContactLink db user $ Just ucl SetProfileAddress onOff -> withUser $ \User {userId} -> - processChatCommand vr nm $ APISetProfileAddress userId onOff + processChatCommand cxt nm $ APISetProfileAddress userId onOff APISetAddressSettings userId settings@AddressSettings {businessAddress, autoAccept} -> withUserId userId $ \user -> do ucl@UserContactLink {userContactLinkId, shortLinkDataSet, addressSettings} <- withFastStore (`getUserAddress` user) forM_ autoAccept $ \AutoAccept {acceptIncognito} -> do @@ -2313,43 +2313,43 @@ processChatCommand vr nm = \case withFastStore' $ \db -> updateUserAddressSettings db userContactLinkId settings pure $ CRUserContactLinkUpdated user ucl'' SetAddressSettings settings -> withUser $ \User {userId} -> - processChatCommand vr nm $ APISetAddressSettings userId settings + processChatCommand cxt nm $ APISetAddressSettings userId settings AcceptContact incognito cName -> withUser $ \User {userId} -> do connReqId <- withFastStore $ \db -> getContactRequestIdByName db userId cName - processChatCommand vr nm $ APIAcceptContact incognito connReqId + processChatCommand cxt nm $ APIAcceptContact incognito connReqId RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withFastStore $ \db -> getContactRequestIdByName db userId cName - processChatCommand vr nm $ APIRejectContact connReqId + processChatCommand cxt nm $ APIRejectContact connReqId ForwardMessage toChatName fromContactName forwardedMsg -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user fromContactName forwardedItemId <- withFastStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg toChatRef <- getChatRef user toChatName asGroup <- getSendAsGroup user toChatRef - processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing + processChatCommand cxt nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user fromGroupName forwardedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg toChatRef <- getChatRef user toChatName asGroup <- getSendAsGroup user toChatRef - processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing + processChatCommand cxt nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) forwardedItemId <- withFastStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg toChatRef <- getChatRef user toChatName asGroup <- getSendAsGroup user toChatRef - processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing + processChatCommand cxt nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing SharePublicGroup shareGroupName toChatName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user shareGroupName toChatRef <- getChatRef user toChatName sendRef <- case toChatRef of ChatRef CTDirect ctId _ -> pure $ SRDirect ctId ChatRef CTGroup gId scope_ -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId pure $ SRGroup gId scope_ (useRelays' gInfo) _ -> throwCmdError "unsupported share target" - processChatCommand vr nm (APIShareChatMsgContent (ChatRef CTGroup groupId Nothing) sendRef) >>= \case + processChatCommand cxt nm (APIShareChatMsgContent (ChatRef CTGroup groupId Nothing) sendRef) >>= \case CRChatMsgContent _ mc -> - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] r -> pure r SendMessage sendName msg -> withUser $ \user -> do let mc = MCText msg @@ -2358,57 +2358,57 @@ processChatCommand vr nm = \case withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do let sendRef = SRDirect ctId - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] Left _ -> - withFastStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case + withFastStore' (\db -> runExceptT $ getActiveMembersByName db cxt user name) >>= \case Right [(gInfo, member)] -> do let GroupInfo {localDisplayName = gName} = gInfo GroupMember {localDisplayName = mName} = member - processChatCommand vr nm $ SendMemberContactMessage gName mName msg + processChatCommand cxt nm $ SendMemberContactMessage gName mName msg Right (suspectedMember : _) -> throwChatError $ CEContactNotFound name (Just suspectedMember) _ -> throwChatError $ CEContactNotFound name Nothing SNGroup name scope_ -> do (gInfo, cScope_, mentions) <- withFastStore $ \db -> do - gInfo <- getGroupInfoByName db vr user name + gInfo <- getGroupInfoByName db cxt user name let gId = groupId' gInfo cScope_ <- forM scope_ $ \(GSNMemberSupport mName_) -> GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ (gInfo, cScope_,) <$> liftIO (getMessageMentions db user gId msg) let sendRef = SRGroup (groupId' gInfo) cScope_ (sendAsGroup' gInfo cScope_) - processChatCommand vr nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] SNLocal -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand vr nm $ APICreateChatItems folderId [composedMessage Nothing mc] + processChatCommand cxt nm $ APICreateChatItems folderId [composedMessage Nothing mc] SendMemberContactMessage gName mName msg -> withUser $ \user -> do (gId, mId) <- getGroupAndMemberId user gName mName - m <- withFastStore $ \db -> getGroupMember db vr user gId mId + m <- withFastStore $ \db -> getGroupMember db cxt user gId mId let mc = MCText msg case memberContactId m of Nothing -> do - g <- withFastStore $ \db -> getGroupInfo db vr user gId + g <- withFastStore $ \db -> getGroupInfo db cxt user gId unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" toView $ CEvtNoMemberContactCreating user g m - processChatCommand vr nm (APICreateMemberContact gId mId) >>= \case + processChatCommand cxt nm (APICreateMemberContact gId mId) >>= \case CRNewMemberContact _ ct@Contact {contactId} _ _ -> do toViewTE $ TENewMemberContact user ct g m - processChatCommand vr nm $ APISendMemberContactInvitation contactId (Just mc) + processChatCommand cxt nm $ APISendMemberContactInvitation contactId (Just mc) cr -> pure cr Just ctId -> do let sendRef = SRDirect ctId - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage Nothing mc] AcceptMemberContact cName -> withUser $ \user -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName - processChatCommand vr nm $ APIAcceptMemberContact contactId + processChatCommand cxt nm $ APIAcceptMemberContact contactId SendLiveMessage chatName msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg withSendRef user chatRef $ \sendRef -> do let mc = MCText msg - processChatCommand vr nm $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] + processChatCommand cxt nm $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast mc -> withUser $ \user -> do - contacts <- withFastStore' $ \db -> getUserContacts db vr user + contacts <- withFastStore' $ \db -> getUserContacts db cxt user withChatLock "sendMessageBroadcast" $ do let ctConns_ = L.nonEmpty $ foldr addContactConn [] contacts case ctConns_ of @@ -2451,28 +2451,28 @@ processChatCommand vr nm = \case contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg let mc = MCText msg - processChatCommand vr nm $ APISendMessages (SRDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] + processChatCommand cxt nm $ APISendMessages (SRDirect contactId) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc M.empty] DeleteMessage chatName deletedMsg -> withUser $ \user -> do chatRef <- getChatRef user chatName deletedItemId <- getSentChatItemIdByText user chatRef deletedMsg - processChatCommand vr nm $ APIDeleteChatItem chatRef (deletedItemId :| []) CIDMBroadcast + processChatCommand cxt nm $ APIDeleteChatItem chatRef (deletedItemId :| []) CIDMBroadcast DeleteMemberMessage gName mName deletedMsg -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user gName deletedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId (Just mName) deletedMsg - processChatCommand vr nm $ APIDeleteMemberChatItem gId (deletedItemId :| []) + processChatCommand cxt nm $ APIDeleteMemberChatItem gId (deletedItemId :| []) EditMessage chatName editedMsg msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg editedItemId <- getSentChatItemIdByText user chatRef editedMsg let mc = MCText msg - processChatCommand vr nm $ APIUpdateChatItem chatRef editedItemId False $ UpdatedMessage mc mentions + processChatCommand cxt nm $ APIUpdateChatItem chatRef editedItemId False $ UpdatedMessage mc mentions UpdateLiveMessage chatName chatItemId live msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg let mc = MCText msg - processChatCommand vr nm $ APIUpdateChatItem chatRef chatItemId live $ UpdatedMessage mc mentions + processChatCommand cxt nm $ APIUpdateChatItem chatRef chatItemId live $ UpdatedMessage mc mentions ReactToMessage add reaction chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg - processChatCommand vr nm $ APIChatItemReaction chatRef chatItemId add reaction + processChatCommand cxt nm $ APIChatItemReaction chatRef chatItemId add reaction APINewGroup userId incognito gProfile -> withUserId userId $ \user -> do g <- asks random memberId <- liftIO $ MemberId <$> encodedRandomBytes g 12 @@ -2480,7 +2480,7 @@ processChatCommand vr nm = \case createNewGroupItems user gInfo pure $ CRGroupCreated user gInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> - processChatCommand vr nm $ APINewGroup userId incognito gProfile + processChatCommand cxt nm $ APINewGroup userId incognito gProfile APINewPublicGroup userId incognito relayIds groupProfile -> withUserId userId $ \user -> do (gProfile', memberId, groupKeys, setupLink) <- prepareGroupLink user gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) (Just 1) @@ -2540,16 +2540,16 @@ processChatCommand vr nm = \case pure (gLink, results) pure (groupProfile', memberId, groupKeys, setupLink) NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} -> - processChatCommand vr nm $ APINewPublicGroup userId incognito relayIds gProfile + processChatCommand cxt nm $ APINewPublicGroup userId incognito relayIds gProfile APIGetGroupRelays groupId -> withUser $ \user -> do (gInfo, relays) <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId relays <- liftIO $ getGroupRelays db gInfo pure (gInfo, relays) pure $ CRGroupRelays user gInfo relays APIAddGroupRelays groupId relayIds -> withUser $ \user -> withGroupLock "addGroupRelays" groupId $ do (gInfo, existingRelays) <- withFastStore $ \db -> do - gi <- getGroupInfo db vr user groupId + gi <- getGroupInfo db cxt user groupId rs <- liftIO $ getGroupRelays db gi pure (gi, rs) assertUserGroupRole gInfo GROwner @@ -2580,7 +2580,7 @@ processChatCommand vr nm = \case _ -> False APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId + (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db cxt user groupId <*> getContact db cxt user contactId let Group gInfo members = group Contact {localDisplayName = cName} = contact when (useRelays' gInfo) $ throwCmdError "can't invite contact to channel" @@ -2612,8 +2612,8 @@ processChatCommand vr nm = \case APIJoinGroup groupId enableNtfs -> withUser $ \user@User {userId} -> do withGroupLock "joinGroup" groupId $ do (invitation, ct) <- withFastStore $ \db -> do - inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId - (inv,) <$> getContactViaMember db vr user fromMember + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId + (inv,) <$> getContactViaMember db cxt user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct @@ -2624,7 +2624,7 @@ processChatCommand vr nm = \case agentConnId <- case memberConn fromMember of Nothing -> do agentConnId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff - let chatV = vr `peerConnChatVersion` peerChatVRange + let chatV = vr cxt `peerConnChatVersion` peerChatVRange void $ withFastStore' $ \db -> createMemberConnection db userId fromMember agentConnId chatV peerChatVRange subMode pure agentConnId Just conn -> pure $ aConnId conn @@ -2643,7 +2643,7 @@ processChatCommand vr nm = \case pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do - (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId + (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user gmId assertUserGroupRole gInfo $ max GRModerator role case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve @@ -2652,14 +2652,14 @@ processChatCommand vr nm = \case Just mConn -> case memberAdmission >>= review of Just MCAll -> do - introduceToModerators vr user gInfo m + introduceToModerators cxt user gInfo m withFastStore' $ \db -> updateGroupMemberStatus db userId m GSMemPendingReview let m' = m {memberStatus = GSMemPendingReview} pure $ CRMemberAccepted user gInfo m' Nothing -> do let msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendDirectMemberMessage mConn msg groupId - introduceToRemaining vr user gInfo m {memberRole = role} + introduceToRemaining cxt user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m GSMemConnected role @@ -2674,7 +2674,7 @@ processChatCommand vr nm = \case Nothing -> throwChatError CEGroupMemberNotActive GSMemPendingReview -> do let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg @@ -2683,7 +2683,7 @@ processChatCommand vr nm = \case let msg2 = XMsgNew $ mcSimple (MCText acceptedToGroupMessage) void $ sendDirectMemberMessage mConn msg2 groupId when (memberCategory m == GCInviteeMember) $ do - introduceToRemaining vr user gInfo m {memberRole = role} + introduceToRemaining cxt user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m newMemberStatus role @@ -2701,7 +2701,7 @@ processChatCommand vr nm = \case _ -> GSMemAnnounced _ -> throwCmdError "member should be pending approval and invitee, or pending review and not invitee" APIDeleteMemberSupportChat groupId gmId -> withUser $ \user -> do - (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId + (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user gmId when (isNothing $ supportChat m) $ throwCmdError "member has no support chat" when (memberPending m) $ throwCmdError "member is pending" (gInfo', m') <- withFastStore' $ \db -> do @@ -2715,7 +2715,7 @@ processChatCommand vr nm = \case APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound @@ -2753,7 +2753,7 @@ processChatCommand vr nm = \case where changeRole :: GroupMember -> CM GroupMember changeRole m@GroupMember {groupMemberId, memberContactId, localDisplayName = cName} = do - withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case + withFastStore (\db -> (,) <$> mapM (getContact db cxt user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case (Just ct, Just cReq) -> do sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq withFastStore' $ \db -> updateGroupMemberRole db user m newRole @@ -2785,7 +2785,7 @@ processChatCommand vr nm = \case APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" -- TODO [relays] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -2834,7 +2834,7 @@ processChatCommand vr nm = \case APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays - Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds memCount = length groupMemberIds @@ -2857,7 +2857,7 @@ processChatCommand vr nm = \case gInfo' <- if useRelays' gInfo then updatePublicGroupData user gInfo - else withFastStore $ \db -> getGroupInfo db vr user groupId + else withFastStore $ \db -> getGroupInfo db cxt user groupId let acis' = map (updateACIGroupInfo gInfo') acis unless (null acis') $ toView $ CEvtNewChatItems user acis' unless (null errs) $ toView $ CEvtChatErrors errs @@ -2929,7 +2929,7 @@ processChatCommand vr nm = \case | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms membership | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do - gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db cxt user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId $ do cancelFilesInProgress user filesInfo @@ -2968,26 +2968,26 @@ processChatCommand vr nm = \case pure msg getRecipients user gInfo | useRelays' gInfo = do - relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + relays <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo pure (relays, relays) | otherwise = do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo pure (ms, filter memberCurrentOrPending ms) APIListMembers groupId -> withUser $ \user -> - CRGroupMembers user <$> withFastStore (\db -> getGroup db vr user groupId) + CRGroupMembers user <$> withFastStore (\db -> getGroup db cxt user groupId) -- -- validate: prohibit to delete/archive if member is pending (has to communicate approval or rejection) -- APIDeleteGroupConversations groupId _gcId -> withUser $ \user -> do - -- _gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- _gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId -- ok_ -- CRGroupConversationsArchived -- APIArchiveGroupConversations groupId _gcId -> withUser $ \user -> do - -- _gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- _gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId -- ok_ -- CRGroupConversationsDeleted AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName - processChatCommand vr nm $ APIAddMember groupId contactId memRole + processChatCommand cxt nm $ APIAddMember groupId contactId memRole JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIJoinGroup groupId enableNtfs + processChatCommand cxt nm $ APIJoinGroup groupId enableNtfs AcceptMember gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIAcceptMember gId gMemberId memRole MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMembersRole gId [gMemberId] memRole BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMembersForAll gId [gMemberId] blocked @@ -2996,45 +2996,45 @@ processChatCommand vr nm = \case gId <- getGroupIdByName db user gName gMemberIds <- mapM (getGroupMemberIdByName db user gId) gMemberNames pure (gId, gMemberIds) - processChatCommand vr nm $ APIRemoveMembers gId gMemberIds withMessages + processChatCommand cxt nm $ APIRemoveMembers gId gMemberIds withMessages LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APILeaveGroup groupId + processChatCommand cxt nm $ APILeaveGroup groupId AllowRelayGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIAllowRelayGroup groupId + processChatCommand cxt nm $ APIAllowRelayGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) + processChatCommand cxt nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) ClearGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIClearChat (ChatRef CTGroup groupId Nothing) + processChatCommand cxt nm $ APIClearChat (ChatRef CTGroup groupId Nothing) ListMembers gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIListMembers groupId + processChatCommand cxt nm $ APIListMembers groupId ListMemberSupportChats gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - (Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + (Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId let memberSupportChats = filter (isJust . supportChat) members pure $ CRMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> - CRGroupsList user <$> withFastStore' (\db -> getBaseGroupDetails db vr user contactId_ search_) + CRGroupsList user <$> withFastStore' (\db -> getBaseGroupDetails db cxt user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do - ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db vr user cName - processChatCommand vr nm $ APIListGroups userId (contactId' <$> ct_) search_ + ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db cxt user cName + processChatCommand cxt nm $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId runUpdateGroupProfile user gInfo p' UpdateGroupNames gName GroupProfile {displayName, fullName, shortDescr} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName, shortDescr} ShowGroupProfile gName -> withUser $ \user -> - CRGroupProfile user <$> withFastStore (\db -> getGroupInfoByName db vr user gName) + CRGroupProfile user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> - CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db vr user gName) + CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do - gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 @@ -3049,7 +3049,7 @@ processChatCommand vr nm = \case gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo gLink APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withGroupLock "groupLinkMemberRole" groupId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId gLnk@GroupLink {acceptMemberRole} <- withFastStore $ \db -> getGroupLink db user gInfo assertUserGroupRole gInfo GRAdmin when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' @@ -3059,22 +3059,22 @@ processChatCommand vr nm = \case else pure gLnk pure $ CRGroupLink user gInfo gLnk' APIDeleteGroupLink groupId -> withUser $ \user -> withGroupLock "deleteGroupLink" groupId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId deleteGroupLink' user gInfo pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId gLnk <- withFastStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo gLnk APIAddGroupShortLink groupId -> withUser $ \user -> do (gInfo, gLink) <- withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId gLink <- getGroupLink db user gInfo pure (gInfo, gLink) gLink' <- setGroupLinkData nm user gInfo gLink pure $ CRGroupLink user gInfo gLink' APICreateMemberContact gId gMemberId -> withUser $ \user -> do - (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId + (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user gId <*> getGroupMember db cxt user gId gMemberId assertUserGroupRole g GRAuthor unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" case memberConn m of @@ -3092,7 +3092,7 @@ processChatCommand vr nm = \case pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do - (g@GroupInfo {groupId}, m, ct, cReq) <- withFastStore $ \db -> getMemberContact db vr user contactId + (g@GroupInfo {groupId}, m, ct, cReq) <- withFastStore $ \db -> getMemberContact db cxt user contactId when (contactGrpInvSent ct) $ throwCmdError "x.grp.direct.inv already sent" case memberConn m of Just mConn -> do @@ -3107,17 +3107,17 @@ processChatCommand vr nm = \case pure $ CRNewMemberContactSentInv user ct' g m _ -> throwChatError CEGroupMemberNotActive APIAcceptMemberContact contactId -> withUser $ \user -> do - (g, mConn, ct, groupDirectInv) <- withFastStore $ \db -> getMemberContactInvited db vr user contactId + (g, mConn, ct, groupDirectInv) <- withFastStore $ \db -> getMemberContactInvited db cxt user contactId when (groupDirectInvStartedConnection groupDirectInv) $ throwCmdError "connection already started" connectMemberContact user g mConn ct groupDirectInv `catchAllErrors` \e -> do -- get updated contact, in case connection was started - ct' <- withFastStore $ \db -> getContact db vr user contactId + ct' <- withFastStore $ \db -> getContact db cxt user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e -- get updated contact (groupDirectInvStartedConnection) with connection ct' <- withFastStore $ \db -> do liftIO $ setMemberContactStartedConnection db ct - getContact db vr user contactId + getContact db cxt user contactId pure $ CRMemberContactAccepted user ct' where connectMemberContact user gInfo mConn Contact {activeConn} GroupDirectInvitation {groupDirectInvLink = cReq} = @@ -3139,7 +3139,7 @@ processChatCommand vr nm = \case acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff conn <- withStore $ \db -> do connId <- liftIO $ createMemberContactConn db user acId Nothing gInfo mConn ConnPrepared contactId subMode - getConnectionById db vr user connId + getConnectionById db cxt user connId joinPreparedConn subMode conn joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile @@ -3150,66 +3150,66 @@ processChatCommand vr nm = \case void $ withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus CreateGroupLink gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APICreateGroupLink groupId mRole + processChatCommand cxt nm $ APICreateGroupLink groupId mRole GroupLinkMemberRole gName mRole -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIGroupLinkMemberRole groupId mRole + processChatCommand cxt nm $ APIGroupLinkMemberRole groupId mRole DeleteGroupLink gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIDeleteGroupLink groupId + processChatCommand cxt nm $ APIDeleteGroupLink groupId ShowGroupLink gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand vr nm $ APIGetGroupLink groupId + processChatCommand cxt nm $ APIGetGroupLink groupId SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do (gInfo, quotedItemId, mentions) <- withFastStore $ \db -> do - gInfo <- getGroupInfoByName db vr user gName + gInfo <- getGroupInfoByName db cxt user gName let gId = groupId' gInfo qiId <- getGroupChatItemIdByText db user gId cName quotedMsg (gInfo, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand vr nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo Nothing)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] + processChatCommand cxt nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo Nothing)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand vr nm $ APIClearChat (ChatRef CTLocal folderId Nothing) + processChatCommand cxt nm $ APIClearChat (ChatRef CTLocal folderId Nothing) LastChats count_ -> withUser' $ \user -> do let count = fromMaybe 5000 count_ - (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user False (PTLast count) clqNoFilters) + (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user False (PTLast count) clqNoFilters) unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs) pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand vr nm $ APIGetChat chatRef Nothing (CPLast count) search + chatResp <- processChatCommand cxt nm $ APIGetChat chatRef Nothing (CPLast count) search pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast count) search + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user (CPLast count) search pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand vr nm $ APIGetChat chatRef Nothing (CPLast $ index + 1) Nothing + chatResp <- processChatCommand cxt nm $ APIGetChat chatRef Nothing (CPLast $ index + 1) Nothing pure $ CRChatItemId user (fmap aChatItemId . listToMaybe . aChatItems . chat $ chatResp) LastChatItemId Nothing index -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast $ index + 1) Nothing + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user (CPLast $ index + 1) Nothing pure $ CRChatItemId user (fmap aChatItemId . listToMaybe $ chatItems) ShowChatItem (Just itemId) -> withUser $ \user -> do chatItem <- withFastStore $ \db -> do chatRef <- getChatRefViaItemId db user itemId - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId pure $ CRChatItems user Nothing ((: []) chatItem) ShowChatItem Nothing -> withUser $ \user -> do - chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast 1) Nothing + chatItems <- withFastStore $ \db -> getAllChatItems db cxt user (CPLast 1) Nothing pure $ CRChatItems user Nothing chatItems ShowChatItemInfo chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName itemId <- getChatItemIdByText user chatRef msg - processChatCommand vr nm $ APIGetChatItemInfo chatRef itemId + processChatCommand cxt nm $ APIGetChatItemInfo chatRef itemId ShowLiveItems on -> withUser $ \_ -> asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName case chatRef of - ChatRef CTLocal folderId _ -> processChatCommand vr nm $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] - _ -> withSendRef user chatRef $ \sendRef -> processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] + ChatRef CTLocal folderId _ -> processChatCommand cxt nm $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] + _ -> withSendRef user chatRef $ \sendRef -> processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName withSendRef user chatRef $ \sendRef -> do @@ -3218,7 +3218,7 @@ processChatCommand vr nm = \case fileSize <- getFileSize filePath unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath} -- TODO include file description for preview - processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] + processChatCommand cxt nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCImage "" fixedImagePreview)] ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> throwCmdError "TODO" @@ -3245,18 +3245,18 @@ processChatCommand vr nm = \case | otherwise -> do cancelSndFile user ftm fts True cref_ <- withFastStore' $ \db -> lookupChatRefByFileId db user fileId - aci_ <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId + aci_ <- withFastStore $ \db -> lookupChatItemByFileId db cxt user fileId case (cref_, aci_) of (Nothing, _) -> pure $ CRSndFileCancelled user Nothing ftm fts (Just (ChatRef CTDirect contactId _), Just aci) -> do - (contact, sharedMsgId) <- withFastStore $ \db -> (,) <$> getContact db vr user contactId <*> getSharedMsgIdByFileId db userId fileId + (contact, sharedMsgId) <- withFastStore $ \db -> (,) <$> getContact db cxt user contactId <*> getSharedMsgIdByFileId db userId fileId void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just (ChatRef CTGroup groupId scope), Just aci) -> do - (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getSharedMsgIdByFileId db userId fileId - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db cxt user groupId <*> getSharedMsgIdByFileId db userId fileId + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion void . sendGroupMessage user gInfo scope recipients $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" @@ -3269,7 +3269,7 @@ processChatCommand vr nm = \case | otherwise -> case xftpRcvFile of Nothing -> do cancelRcvFileTransfer user ftr - ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId + ci <- withFastStore $ \db -> lookupChatItemByFileId db cxt user fileId pure $ CRRcvFileCancelled user ci ftr Just XFTPRcvFile {agentRcvFileId} -> do forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do @@ -3280,7 +3280,7 @@ processChatCommand vr nm = \case aci_ <- resetRcvCIFileStatus user fileId CIFSRcvInvitation pure $ CRRcvFileCancelled user aci_ ftr FileStatus fileId -> withUser $ \user -> do - withFastStore (\db -> lookupChatItemByFileId db vr user fileId) >>= \case + withFastStore (\db -> lookupChatItemByFileId db cxt user fileId) >>= \case Nothing -> do fileStatus <- withFastStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus @@ -3309,7 +3309,7 @@ processChatCommand vr nm = \case let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user} updateProfile user p SetContactFeature (ACF f) cName allowed_ -> withUser $ \user -> do - ct@Contact {userPreferences} <- withFastStore $ \db -> getContactByName db vr user cName + ct@Contact {userPreferences} <- withFastStore $ \db -> getContactByName db cxt user cName let prefs' = setPreference f allowed_ $ Just userPreferences updateContactPrefs user ct prefs' SetGroupFeature (AGFNR f) gName enabled -> @@ -3329,7 +3329,7 @@ processChatCommand vr nm = \case p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference' SCFTimedMessages (Just pref) $ preferences' user} updateProfile user p SetContactTimedMessages cName timedMessagesEnabled_ -> withUser $ \user -> do - ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withFastStore $ \db -> getContactByName db vr user cName + ct@Contact {userPreferences = userPreferences@Preferences {timedMessages}} <- withFastStore $ \db -> getContactByName db cxt user cName let currentTTL = timedMessages >>= \TimedMessagesPreference {ttl} -> ttl pref_ = tmeToPref currentTTL <$> timedMessagesEnabled_ prefs' = setPreference' SCFTimedMessages pref_ $ Just userPreferences @@ -3444,7 +3444,7 @@ processChatCommand vr nm = \case _ -> throwCmdError "not supported" pure $ ChatRef cType chatId Nothing getSendAsGroup :: User -> ChatRef -> CM ShowGroupAsSender - getSendAsGroup user' (ChatRef CTGroup chatId scope) = (`sendAsGroup'` scope) <$> withFastStore (\db -> getGroupInfo db vr user' chatId) + getSendAsGroup user' (ChatRef CTGroup chatId scope) = (`sendAsGroup'` scope) <$> withFastStore (\db -> getGroupInfo db cxt user' chatId) getSendAsGroup _ _ = pure False getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) getChatRefAndMentions user cName msg = do @@ -3463,13 +3463,13 @@ processChatCommand vr nm = \case checkStoreNotChanged :: CM ChatResponse -> CM ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) withUserName :: UserName -> (UserId -> ChatCommand) -> CM ChatResponse - withUserName uName cmd = withFastStore (`getUserIdByName` uName) >>= processChatCommand vr nm . cmd + withUserName uName cmd = withFastStore (`getUserIdByName` uName) >>= processChatCommand cxt nm . cmd withContactName :: ContactName -> (ContactId -> ChatCommand) -> CM ChatResponse withContactName cName cmd = withUser $ \user -> - withFastStore (\db -> getContactIdByName db user cName) >>= processChatCommand vr nm . cmd + withFastStore (\db -> getContactIdByName db user cName) >>= processChatCommand cxt nm . cmd withMemberName :: GroupName -> ContactName -> (GroupId -> GroupMemberId -> ChatCommand) -> CM ChatResponse withMemberName gName mName cmd = withUser $ \user -> - getGroupAndMemberId user gName mName >>= processChatCommand vr nm . uncurry cmd + getGroupAndMemberId user gName mName >>= processChatCommand cxt nm . uncurry cmd getConnectionCode :: ConnId -> CM Text getConnectionCode connId = verificationCode <$> withAgent (`getConnectionRatchetAdHash` connId) verifyConnectionCode :: User -> Connection -> Maybe Text -> CM ChatResponse @@ -3503,7 +3503,7 @@ processChatCommand vr nm = \case -- TODO PQ the error above should be CEIncompatibleConnReqVersion, also the same API should be called in Plan Just (agentV, pqSup') -> do let chatV = agentToChatVersion agentV - withFastStore' (\db -> getConnectionEntityByConnReq db vr user cReqs) >>= \case + withFastStore' (\db -> getConnectionEntityByConnReq db cxt user cReqs) >>= \case Nothing -> joinNewConn chatV Just (RcvDirectMsgConnection conn@Connection {connStatus, contactConnInitiated, customUserProfileId} _ct_) | connStatus == ConnNew && contactConnInitiated -> joinNewConn chatV -- own connection link @@ -3547,7 +3547,7 @@ processChatCommand vr nm = \case ConnPrepared -> joinPreparedConn' xContactId conn (Just $ Just gInfo) _ -> connect' groupLinkId xContactId (Just $ Just gInfo) -- why not "already connected" for host member? Nothing -> - withFastStore' (\db -> getConnReqContactXContactId db vr user cReqHash1 cReqHash2) >>= \case + withFastStore' (\db -> getConnReqContactXContactId db cxt user cReqHash1 cReqHash2) >>= \case Right ct@Contact {activeConn} -> case groupLinkId of Nothing -> case activeConn of Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn Nothing @@ -3602,7 +3602,7 @@ processChatCommand vr nm = \case let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup - ct' <- withStore $ \db -> getContact db vr user contactId + ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of ConnPrepared -> do @@ -3611,14 +3611,14 @@ processChatCommand vr nm = \case localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn - ct' <- withStore $ \db -> getContact db vr user contactId + ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" connectToRelay :: User -> GroupInfo -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ()) connectToRelay user gInfo relayLink = do gVar <- asks random -- Save relayLink to re-use relay member record on retry (check by relayLink) - relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink + relayMember <- withFastStore $ \db -> getCreateRelayForMember db cxt gVar user gInfo relayLink r <- tryAllErrors $ do (fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink relayLinkData_ <- liftIO $ decodeLinkUserData cData @@ -3629,11 +3629,11 @@ processChatCommand vr nm = \case let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing - relayMember' <- withFastStore $ \db -> getGroupMember db vr user (groupId' gInfo) (groupMemberId' relayMember) + relayMember' <- withFastStore $ \db -> getGroupMember db cxt user (groupId' gInfo) (groupMemberId' relayMember) pure (relayLink, relayMember', r) syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () syncSubscriberRelays user gInfo currentRelayLinks = void . tryAllErrors $ do - localRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + localRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo let activeRelayMembers = filter memberCurrent localRelayMembers memberRelayLink GroupMember {relayLink = rl} = rl localRelayLinks = mapMaybe memberRelayLink activeRelayMembers @@ -3703,7 +3703,7 @@ processChatCommand vr nm = \case | otherwise = do when (n /= n') $ checkValidName n' -- read contacts before user update to correctly merge preferences - contacts <- withFastStore' $ \db -> getUserContacts db vr user + contacts <- withFastStore' $ \db -> getUserContacts db cxt user user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" $ do @@ -3755,7 +3755,7 @@ processChatCommand vr nm = \case (conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId])) setMyAddressData :: User -> UserContactLink -> CM UserContactLink setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do - conn <- withFastStore $ \db -> getUserAddressConnection db vr user + conn <- withFastStore $ \db -> getUserAddressConnection db cxt user let shortLinkProfile = userProfileDirect user Nothing Nothing True -- TODO [short links] do not save address to server if data did not change, spinners, error handling userData @@ -3789,12 +3789,12 @@ processChatCommand vr nm = \case gInfo' <- withStore $ \db -> updateGroupProfile db user gInfo p' msg <- case businessChat of Just BusinessChatInfo {businessId} -> do - ms <- withStore' $ \db -> getGroupMembers db vr user gInfo' + ms <- withStore' $ \db -> getGroupMembers db cxt user gInfo' let (newMs, oldMs) = partition (\m -> maxVersion (memberChatVRange m) >= businessChatPrefsVersion) ms -- this is a fallback to send the members with the old version correct profile of the business when preferences change unless (null oldMs) $ do GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} <- - withStore $ \db -> getGroupMemberByMemberId db vr user gInfo' businessId + withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo' businessId let p'' = p' {displayName, fullName, shortDescr, image} :: GroupProfile recipients = filter memberCurrentOrPending oldMs void $ sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p'') @@ -3807,9 +3807,9 @@ processChatCommand vr nm = \case sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p') where getRecipients - | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo' + | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo' | otherwise = do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo' + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo' pure $ filter memberCurrentOrPending ms let cd = CDGroupSnd gInfo' Nothing unless (sameGroupProfileInfo p p') $ do @@ -3870,13 +3870,13 @@ processChatCommand vr nm = \case updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do gInfo@GroupInfo {groupProfile = p} <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroupInfo db vr user + getGroupIdByName db user gName >>= getGroupInfo db cxt user runUpdateGroupProfile user gInfo $ update p withCurrentCall :: ContactId -> (User -> Contact -> Call -> CM (Maybe Call)) -> CM ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do user <- getUserByContactId db ctId - (user,) <$> getContact db vr user ctId + (user,) <$> getContact db cxt user ctId calls <- asks currentCalls withContactLock "currentCall" ctId $ atomically (TM.lookup ctId calls) >>= \case @@ -3918,7 +3918,7 @@ processChatCommand vr nm = \case FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \XFTPSndFile {cryptoArgs} -> cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where - forward path cfArgs = processChatCommand vr nm $ sendCommand chatName $ CryptoFile path cfArgs + forward path cfArgs = processChatCommand cxt nm $ sendCommand chatName $ CryptoFile path cfArgs getGroupAndMemberId :: User -> GroupName -> ContactName -> CM (GroupId, GroupMemberId) getGroupAndMemberId user gName groupMemberName = withStore $ \db -> do @@ -3930,7 +3930,7 @@ processChatCommand vr nm = \case checkValidName displayName -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - withFastStore $ \db -> createNewGroup db vr user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ + withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ createNewGroupItems :: User -> GroupInfo -> CM () createNewGroupItems user gInfo = do let cd = CDGroupSnd gInfo Nothing @@ -3973,9 +3973,9 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff (relayMember, conn, groupRelay) <- withFastStore $ \db -> do - relayMember <- createRelayForOwner db vr gVar user gInfo relay + relayMember <- createRelayForOwner db cxt gVar user gInfo relay groupRelay <- createGroupRelayRecord db gInfo relayMember relay - conn <- createRelayConnection db vr user (groupMemberId' relayMember) connId ConnPrepared chatV subMode + conn <- createRelayConnection db cxt user (groupMemberId' relayMember) connId ConnPrepared chatV subMode pure (relayMember, conn, groupRelay) let GroupMember {memberRole = userRole, memberId = userMemberId} = membership allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo @@ -4047,15 +4047,15 @@ processChatCommand vr nm = \case (chatId, chatSettings) <- case cType of CTDirect -> withFastStore $ \db -> do ctId <- getContactIdByName db user name - Contact {chatSettings} <- getContact db vr user ctId + Contact {chatSettings} <- getContact db cxt user ctId pure (ctId, chatSettings) CTGroup -> withFastStore $ \db -> do gId <- getGroupIdByName db user name - GroupInfo {chatSettings} <- getGroupInfo db vr user gId + GroupInfo {chatSettings} <- getGroupInfo db cxt user gId pure (gId, chatSettings) _ -> throwCmdError "not supported" - processChatCommand vr nm $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings + processChatCommand cxt nm $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings connectPlan :: User -> AConnectionLink -> Bool -> Maybe LinkOwnerSig -> CM (ACreatedConnLink, ConnectionPlan) connectPlan user (ACL SCMInvitation cLink) _ sig_ = case cLink of CLFull cReq -> invitationReqAndPlan cReq Nothing Nothing Nothing @@ -4071,10 +4071,10 @@ processChatCommand vr nm = \case where knownLinkPlans l' = withFastStore $ \db -> do let inv cReq = ACCL SCMInvitation $ CCLink cReq (Just l') - liftIO (getConnectionEntityViaShortLink db vr user l') >>= \case + liftIO (getConnectionEntityViaShortLink db cxt user l') >>= \case Just (cReq, ent) -> pure $ Just (inv cReq, invitationEntityPlan Nothing Nothing ent) -- deleted contact is returned as known, as invitation link cannot be re-used too connect anyway - Nothing -> bimap inv (CPInvitationLink . ILPKnown) <$$> getContactViaShortLinkToConnect db vr user l' + Nothing -> bimap inv (CPInvitationLink . ILPKnown) <$$> getContactViaShortLinkToConnect db cxt user l' invitationReqAndPlan cReq sLnk_ cld ov = do plan <- invitationRequestPlan user cReq cld ov `catchAllErrors` (pure . CPError) pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan) @@ -4089,7 +4089,7 @@ processChatCommand vr nm = \case Just r -> pure r Nothing -> do (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' - withFastStore' (\db -> getContactWithoutConnViaShortAddress db vr user l') >>= \case + withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do contactSLinkData_ <- liftIO $ decodeLinkUserData cData @@ -4102,9 +4102,9 @@ processChatCommand vr nm = \case liftIO (getUserContactLinkViaShortLink db user l') >>= \case Just UserContactLink {connLinkContact = CCLink cReq _} -> pure $ Just (con cReq, CPContactAddress CAPOwnLink) Nothing -> - getContactViaShortLinkToConnect db vr user l' >>= \case + getContactViaShortLinkToConnect db cxt user l' >>= \case Just (cReq, ct') -> pure $ if contactDeleted ct' then Nothing else Just (con cReq, CPContactAddress (CAPKnown ct')) - Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l' + Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db cxt user l' CCTGroup -> groupShortLinkPlan CCTChannel -> groupShortLinkPlan CCTRelay -> throwCmdError "chat relay links are not supported in this version" @@ -4140,9 +4140,9 @@ processChatCommand vr nm = \case Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel _ -> False knownLinkPlans = withFastStore $ \db -> - liftIO (getGroupInfoViaUserShortLink db vr user l') >>= \case + liftIO (getGroupInfoViaUserShortLink db cxt user l') >>= \case Just (cReq, g) -> pure $ Just (con cReq, CPGroupLink (GLPOwnLink g)) - Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l' + Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db cxt user l' resolveKnownGroup g = do (fd@FixedLinkData {rootKey = rk}, cData@(ContactLinkData _ UserContactData {owners})) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData @@ -4158,13 +4158,13 @@ processChatCommand vr nm = \case case plan of CPError e -> eToView e; _ -> pure () case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) -> - processChatCommand vr nm $ APIConnectContactViaAddress userId incognito contactId - _ -> processChatCommand vr nm $ APIConnect userId incognito $ Just ccLink + processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId + _ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink | otherwise = pure $ CRConnectionPlan user ccLink plan invitationRequestPlan :: User -> ConnReqInvitation -> Maybe ContactShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan invitationRequestPlan user cReq cld ov = do maybe (CPInvitationLink (ILPOk cld ov)) (invitationEntityPlan cld ov) - <$> withFastStore' (\db -> getConnectionEntityByConnReq db vr user $ invCReqSchemas cReq) + <$> withFastStore' (\db -> getConnectionEntityByConnReq db cxt user $ invCReqSchemas cReq) where invCReqSchemas :: ConnReqInvitation -> (ConnReqInvitation, ConnReqInvitation) invCReqSchemas (CRInvitationUri crData e2e) = @@ -4196,9 +4196,9 @@ processChatCommand vr nm = \case withFastStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> - withFastStore' (\db -> getContactConnEntityByConnReqHash db vr user cReqHashes) >>= \case + withFastStore' (\db -> getContactConnEntityByConnReqHash db cxt user cReqHashes) >>= \case Nothing -> - withFastStore' (\db -> getContactWithoutConnViaAddress db vr user cReqSchemas) >>= \case + withFastStore' (\db -> getContactWithoutConnViaAddress db cxt user cReqSchemas) >>= \case Just ct | not (contactDeleted ct) -> pure $ CPContactAddress (CAPContactViaAddress ct) _ -> pure $ CPContactAddress (CAPOk cld ov) Just (RcvDirectMsgConnection Connection {connStatus} Nothing) @@ -4215,11 +4215,11 @@ processChatCommand vr nm = \case groupJoinRequestPlan user (CRContactUri crData) linkInfo gld ov = do let cReqSchemas = contactCReqSchemas crData cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas - withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db vr user cReqSchemas) >>= \case + withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas) >>= \case Just g -> pure $ CPGroupLink (GLPOwnLink g) Nothing -> do - connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db vr user cReqHashes - gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db vr user cReqHashes + connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db cxt user cReqHashes + gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db cxt user cReqHashes case (gInfo_, connEnt_) of (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk linkInfo gld ov) -- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?) @@ -4274,7 +4274,7 @@ processChatCommand vr nm = \case shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId' conn) SCMInvitation userLinkData Nothing) updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do - AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId + AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db cxt user groupId case (cInfo, content) of (DirectChat ct@Contact {contactId}, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) | status == CIGISPending -> do @@ -4297,7 +4297,7 @@ processChatCommand vr nm = \case sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId assertDirectAllowed user MDSnd ct XMsgNew_ assertVoiceAllowed ct processComposedMessages ct @@ -4354,8 +4354,8 @@ processChatCommand vr nm = \case sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL cmrs = do assertMultiSendable live cmrs - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope - recipients <- getGroupRecipients vr user gInfo chatScopeInfo modsCompatVersion + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope + recipients <- getGroupRecipients cxt user gInfo chatScopeInfo modsCompatVersion sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs @@ -4500,7 +4500,7 @@ processChatCommand vr nm = \case throwError err getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect]) getCommandDirectChatItems user ctId itemIds = do - ct <- withFastStore $ \db -> getContact db vr user ctId + ct <- withFastStore $ \db -> getContact db cxt user ctId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) unless (null errs) $ toView $ CEvtChatErrors errs pure (ct, items) @@ -4509,7 +4509,7 @@ processChatCommand vr nm = \case getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup]) getCommandGroupChatItems user gId itemIds = do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db gInfo) (L.toList itemIds)) unless (null errs) $ toView $ CEvtChatErrors errs pure (gInfo, items) @@ -4568,7 +4568,7 @@ processChatCommand vr nm = \case withSendRef user chatRef a = case chatRef of ChatRef CTDirect cId _ -> a $ SRDirect cId ChatRef CTGroup gId scope -> do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user gId a $ SRGroup gId scope (sendAsGroup' gInfo scope) _ -> throwCmdError "not supported" getSharedMsgId :: CM SharedMsgId @@ -4759,17 +4759,17 @@ cleanupManager = do timedItems <- withStore' $ \db -> getTimedItems db user startTimedThreadCutoff forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchAllErrors` const (pure ()) cleanupDeletedContacts user = do - vr <- chatVersionRange - contacts <- withStore' $ \db -> getDeletedContacts db vr user + cxt <- chatStoreCxt + contacts <- withStore' $ \db -> getDeletedContacts db cxt user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) `catchAllErrors` eToView cleanupInProgressGroups user = do - vr <- chatVersionRange + cxt <- chatStoreCxt ts <- liftIO getCurrentTime -- older than 30 minutes to avoid deleting a newly created group let cutoffTs = addUTCTime (- 1800) ts - inProgressGroups <- withStore' $ \db -> getInProgressGroups db vr user cutoffTs + inProgressGroups <- withStore' $ \db -> getInProgressGroups db cxt user cutoffTs forM_ inProgressGroups $ \gInfo -> deleteInProgressGroup user gInfo `catchAllErrors` eToView cleanupStaleRelayTestConns user = do @@ -4814,8 +4814,8 @@ runRelayGroupLinkChecks user = do liftIO $ threadDelay' $ diffToMicroseconds interval where checkRelayServedGroups = do - vr <- chatVersionRange - relayGroups <- withStore' $ \db -> getRelayServedGroups db vr user + cxt <- chatStoreCxt + relayGroups <- withStore' $ \db -> getRelayServedGroups db cxt user forM_ relayGroups $ \gInfo@GroupInfo {groupProfile = gp} -> flip catchAllErrors eToView $ do case publicGroup gp of Just PublicGroupProfile {groupLink = sLnk} -> do @@ -4833,24 +4833,24 @@ runRelayGroupLinkChecks user = do _ -> pure () _ -> pure () checkRelayInactiveGroups = do - vr <- chatVersionRange + cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) - inactiveGroups <- withStore' $ \db -> getRelayInactiveGroups db vr user ttl + inactiveGroups <- withStore' $ \db -> getRelayInactiveGroups db cxt user ttl forM_ inactiveGroups $ \gInfo -> flip catchAllErrors eToView $ deleteGroupConnections user gInfo False expireChatItems :: User -> Int64 -> Bool -> CM () expireChatItems user@User {userId} globalTTL sync = do currentTs <- liftIO getCurrentTime - vr <- chatVersionRange + cxt <- chatStoreCxt -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs lift waitChatStartedAndActivated contactIds <- withStore' $ \db -> getUserContactsToExpire db user globalTTL - loop contactIds $ expireContactChatItems user vr globalTTL + loop contactIds $ expireContactChatItems user cxt globalTTL lift waitChatStartedAndActivated groupIds <- withStore' $ \db -> getUserGroupsToExpire db user globalTTL - loop groupIds $ expireGroupChatItems user vr globalTTL createdAtCutoff + loop groupIds $ expireGroupChatItems user cxt globalTTL createdAtCutoff where loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () @@ -4866,11 +4866,11 @@ expireChatItems user@User {userId} globalTTL sync = do expire <- atomically $ TM.lookup userId expireFlags when (expire == Just True) $ threadDelay 100000 >> a -expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM () -expireContactChatItems user vr globalTTL ctId = +expireContactChatItems :: User -> StoreCxt -> Int64 -> ContactId -> CM () +expireContactChatItems user cxt globalTTL ctId = -- reading contacts and groups inside the loop, -- to allow ttl changing while processing and to reduce memory usage - tryAllErrors (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process + tryAllErrors (withStore $ \db -> getContact db cxt user ctId) >>= mapM_ process where process ct@Contact {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do @@ -4879,9 +4879,9 @@ expireContactChatItems user vr globalTTL ctId = deleteCIFiles user filesInfo withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate -expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () -expireGroupChatItems user vr globalTTL createdAtCutoff groupId = - tryAllErrors (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process +expireGroupChatItems :: User -> StoreCxt -> Int64 -> UTCTime -> GroupId -> CM () +expireGroupChatItems user cxt globalTTL createdAtCutoff groupId = + tryAllErrors (withStore $ \db -> getGroupInfo db cxt user groupId) >>= mapM_ process where process gInfo@GroupInfo {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do @@ -4889,7 +4889,7 @@ expireGroupChatItems user vr globalTTL createdAtCutoff groupId = filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff deleteCIFiles user filesInfo withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db cxt user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m withExpirationDate :: Int64 -> Maybe Int64 -> (UTCTime -> CM ()) -> CM () diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 31a1d60502..f2c448d5b8 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -473,12 +473,12 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do deleteCIFiles user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) unless (null errs) $ toView $ CEvtChatErrors errs - vr <- chatVersionRange + cxt <- chatStoreCxt deletions' <- case chatScopeInfo of Nothing -> pure deletions Just scopeInfo@GCSIMemberSupport {groupMember_} -> do let decStats = countDeletedUnreadItems groupMember_ deletions - gInfo' <- withFastStore' $ \db -> updateGroupScopeUnreadStats db vr user gInfo scopeInfo decStats + gInfo' <- withFastStore' $ \db -> updateGroupScopeUnreadStats db cxt user gInfo scopeInfo decStats pure $ map (updateDeletionGroupInfo gInfo') deletions pure deletions' where @@ -689,7 +689,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI unless (fileStatus == RFSNew) $ case fileStatus of RFSCancelled _ -> throwChatError $ CEFileCancelled fName _ -> throwChatError $ CEFileAlreadyReceiving fName - vr <- chatVersionRange + cxt <- chatStoreCxt case (xftpRcvFile, fileConnReq) of -- XFTP (Just XFTPRcvFile {userApprovedRelays = approvedBeforeReady}, _) -> do @@ -698,7 +698,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI (ci, rfd) <- withStore $ \db -> do -- marking file as accepted and reading description in the same transaction -- to prevent race condition with appending description - ci <- xftpAcceptRcvFT db vr user fileId filePath userApproved + ci <- xftpAcceptRcvFT db cxt user fileId filePath userApproved rfd <- getRcvFileDescrByRcvFileId db fileId pure (ci, rfd) receiveViaCompleteFD user fileId rfd userApproved cryptoArgs @@ -709,10 +709,10 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI chatRef <- withStore $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of (ChatRef CTDirect contactId _, Nothing) -> do - ct <- withStore $ \db -> getContact db vr user contactId + ct <- withStore $ \db -> getContact db cxt user contactId acceptFile $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId _, Just memId) -> do - GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId + GroupMember {activeConn} <- withStore $ \db -> getGroupMember db cxt user groupId memId case activeConn of Just conn -> do acceptFile $ \msg -> void $ sendDirectMemberMessage conn msg groupId @@ -723,12 +723,12 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI acceptFile send = do filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline - vr <- chatVersionRange + cxt <- chatStoreCxt if | inline -> do -- accepting inline (ci, sharedMsgId) <- withStore $ \db -> - liftM2 (,) (acceptRcvInlineFT db vr user fileId filePath) (getSharedMsgIdByFileId db userId fileId) + liftM2 (,) (acceptRcvInlineFT db cxt user fileId filePath) (getSharedMsgIdByFileId db userId fileId) send $ XFileAcptInv sharedMsgId Nothing fName pure ci | fileInline == Just IFMSent -> throwChatError $ CEFileAlreadyReceiving fName @@ -804,13 +804,13 @@ getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig resetRcvCIFileStatus :: User -> FileTransferId -> CIFileStatus 'MDRcv -> CM (Maybe AChatItem) resetRcvCIFileStatus user fileId ciFileStatus = do - vr <- chatVersionRange + cxt <- chatStoreCxt withStore $ \db -> do liftIO $ do updateCIFileStatus db user fileId ciFileStatus updateRcvFileStatus db fileId FSNew updateRcvFileAgentId db fileId Nothing - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId receiveViaURI :: User -> FileDescriptionURI -> CryptoFile -> CM RcvFileTransfer receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile {cryptoArgs} = do @@ -828,11 +828,11 @@ receiveViaURI user@User {userId} FileDescriptionURI {description} cf@CryptoFile startReceivingFile :: User -> FileTransferId -> CM () startReceivingFile user fileId = do - vr <- chatVersionRange + cxt <- chatStoreCxt ci <- withStore $ \db -> do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileStart user ci getRcvFilePath :: FileTransferId -> Maybe FilePath -> String -> Bool -> CM FilePath @@ -883,8 +883,8 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId subMode <- chatReadVar subscriptionMode let pqSup = PQSupportOn pqSup' = pqSup `CR.pqSupportAnd` pqSupport - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (ct, conn, incognitoProfile) <- case contactId_ of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing @@ -893,7 +893,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId createContactFromRequest db user userContactLinkId_ connId chatV cReqChatVRange cName profileId cp xContactId incognitoProfile subMode pqSup' False pure (ct, conn, incognitoProfile) Just contactId -> do - ct <- withFastStore $ \db -> getContact db vr user contactId + ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConn ct of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing @@ -920,15 +920,15 @@ acceptContactRequestAsync incognitoProfile = do subMode <- chatReadVar subscriptionMode let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV currentTs <- liftIO getCurrentTime withStore $ \db -> do forM_ xContactId $ \xcId -> liftIO $ setContactAcceptedXContactId db ct xcId Connection {connId} <- liftIO $ createAcceptedContactConn db user (Just uclId) contactId acId chatV cReqChatVRange cReqPQSup incognitoProfile subMode currentTs liftIO $ setCommandConnId db user cmdId connId - getContact db vr user contactId + getContact db cxt user contactId acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember acceptGroupJoinRequestAsync @@ -964,12 +964,12 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId acceptGroupJoinSendRejectAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> GroupRejectionReason -> CM GroupMember acceptGroupJoinSendRejectAsync @@ -994,12 +994,12 @@ acceptGroupJoinSendRejectAsync rejectionReason } subMode <- chatReadVar subscriptionMode - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) acceptBusinessJoinRequestAsync @@ -1008,7 +1008,7 @@ acceptBusinessJoinRequestAsync gInfo@GroupInfo {membership = GroupMember {memberRole = userRole, memberId = userMemberId}} clientMember@GroupMember {groupMemberId, memberId} UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId} = do - vr <- chatVersionRange + cxt <- chatStoreCxt let userProfile@Profile {displayName, preferences} = fromLocalProfile $ profile' user -- TODO [short links] take groupPreferences from group info groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences @@ -1027,7 +1027,7 @@ acceptBusinessJoinRequestAsync groupSize = Just 1 } subMode <- chatReadVar subscriptionMode - let chatV = vr `peerConnChatVersion` cReqChatVRange + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore' $ \db -> do forM_ xContactId $ \xcId -> setBusinessChatAcceptedXContactId db gInfo xcId @@ -1051,28 +1051,28 @@ acceptRelayJoinRequestAsync -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities subMode <- chatReadVar subscriptionMode - vr <- chatVersionRange - let chatV = vr `peerConnChatVersion` cReqChatVRange + cxt <- chatStoreCxt + let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted - ownerMember' <- getGroupMemberById db vr user groupMemberId + ownerMember' <- getGroupMemberById db cxt user groupMemberId pure (gInfo', ownerMember') rejectRelayInvitationAsync :: User -> Int64 - -> VersionRangeChat + -> StoreCxt -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> RelayRejectionReason -> CM () -rejectRelayInvitationAsync user uclId vr groupRelayInv invId reqChatVRange initialDelay reason = do +rejectRelayInvitationAsync user uclId cxt groupRelayInv invId reqChatVRange initialDelay reason = do (_gInfo, ownerMember) <- withStore $ \db -> - createRelayRequestGroup db vr user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected + createRelayRequestGroup db cxt user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected let GroupMember {groupMemberId} = ownerMember msg = XGrpRelayReject reason subMode <- chatReadVar subscriptionMode @@ -1086,15 +1086,15 @@ businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences = GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} -introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do +introduceToModerators :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceToModerators cxt user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do forM_ (memberConn m) $ \mConn -> do let msg = if maxVersion (memberChatVRange m) >= groupKnockingVersion then XGrpLinkAcpt GAPendingReview memberRole memberId else XMsgNew $ mcSimple (MCText pendingReviewMessage) void $ sendDirectMemberMessage mConn msg groupId - modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs = filter shouldIntroduceToMod modMs introduceMember user gInfo m rcpModMs (Just $ MSMember $ memberId' m) where @@ -1104,15 +1104,15 @@ introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRol && groupMemberId' mem /= groupMemberId' m && maxVersion (memberChatVRange mem) >= groupKnockingVersion -introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToAll vr user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db vr user gInfo) (getMemberRelationsVector db m) +introduceToAll :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceToAll cxt user gInfo m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing -introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToRemaining vr user gInfo m = do - (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db vr user gInfo) (getMemberRelationsVector db m) +introduceToRemaining :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () +introduceToRemaining cxt user gInfo m = do + (members, vector) <- withStore $ \db -> liftM2 (,) (liftIO $ getGroupMembers db cxt user gInfo) (getMemberRelationsVector db m) let recipients = filter (shouldIntroduce m vector) members introduceMember user gInfo m recipients Nothing @@ -1166,10 +1166,10 @@ memberIntroEvt gInfo reMember = -- Used in groups with relays to introduce moderators and above to a new member, -- and to announce the new member to moderators and above. -- This doesn't create introduction records in db, compared to above methods. -introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn} = do - modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo +introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn} = do + modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing let introEvts = map (memberIntroEvt gInfo) modMs forM_ (L.nonEmpty introEvts) $ \introEvts' -> @@ -1328,9 +1328,9 @@ setGroupLinkData' nm user gInfo = setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink setGroupLinkData nm user gInfo gLink = do - vr <- chatVersionRange + cxt <- chatStoreCxt (conn, groupRelays) <- withFastStore $ \db -> - (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays linkType = if useRelays' gInfo then CCTChannel else CCTGroup sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData)) @@ -1338,17 +1338,17 @@ setGroupLinkData nm user gInfo gLink = do setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () setGroupLinkDataAsync user gInfo gLink = do - vr <- chatVersionRange + cxt <- chatStoreCxt (conn, groupRelays) <- withStore $ \db -> - (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM () connectToRelayAsync user gInfo relayLink = do - vr <- chatVersionRange + cxt <- chatStoreCxt gVar <- asks random - relayMember@GroupMember {activeConn} <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink + relayMember@GroupMember {activeConn} <- withFastStore $ \db -> getCreateRelayForMember db cxt gVar user gInfo relayLink case activeConn of Just _ -> pure () Nothing -> do @@ -1359,9 +1359,9 @@ connectToRelayAsync user gInfo relayLink = do updatePublicGroupData :: User -> GroupInfo -> CM GroupInfo updatePublicGroupData user gInfo | useRelays' gInfo && memberRole' (membership gInfo) == GROwner = do - vr <- chatVersionRange + cxt <- chatStoreCxt (gInfo', gLink) <- withStore $ \db -> do - gInfo' <- updatePublicMemberCount db vr user gInfo + gInfo' <- updatePublicMemberCount db cxt user gInfo gLink <- getGroupLink db user gInfo' pure (gInfo', gLink) setGroupLinkDataAsync user gInfo' gLink @@ -1371,12 +1371,12 @@ updatePublicGroupData user gInfo updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> CM (GroupInfo, Bool) updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData} | profileChanged || countChanged = do - vr <- chatVersionRange + cxt <- chatStoreCxt withStore $ \db -> do g <- if profileChanged then updateGroupProfile db user gInfo groupProfile else pure gInfo g' <- case publicGroupData of Just PublicGroupData {publicMemberCount} | countChanged -> - setPublicMemberCount db vr user g publicMemberCount + setPublicMemberCount db cxt user g publicMemberCount _ -> pure g pure (g', profileChanged) | otherwise = pure (gInfo, False) @@ -1455,14 +1455,14 @@ shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' s deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do - vr <- chatVersionRange - conn <- withStore $ \db -> getGroupLinkConnection db vr user gInfo + cxt <- chatStoreCxt + conn <- withStore $ \db -> getGroupLinkConnection db cxt user gInfo deleteGroupLink_ user gInfo conn deleteGroupLinkIfExists :: User -> GroupInfo -> CM () deleteGroupLinkIfExists user gInfo = do - vr <- chatVersionRange - conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db vr user gInfo) + cxt <- chatStoreCxt + conn_ <- eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupLinkConnection db cxt user gInfo) mapM_ (deleteGroupLink_ user gInfo) conn_ deleteGroupLink_ :: User -> GroupInfo -> Connection -> CM () @@ -1497,16 +1497,16 @@ deleteTimedItem user (ChatRef cType chatId scope, itemId) deleteAt = do ts <- liftIO getCurrentTime liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts lift waitChatStartedAndActivated - vr <- chatVersionRange + cxt <- chatStoreCxt case cType of CTDirect -> do - (ct, ci) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId + (ct, ci) <- withStore $ \db -> (,) <$> getContact db cxt user chatId <*> getDirectChatItem db user chatId itemId deletions <- deleteDirectCIs user ct [ci] toView $ CEvtChatItemsDeleted user deletions True True CTGroup -> do - (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId + (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db cxt user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope deletions <- deleteGroupCIs user gInfo chatScopeInfo [ci] Nothing deletedTs toView $ CEvtChatItemsDeleted user deletions True True _ -> eToView $ ChatError $ CEInternalError "bad deleteTimedItem cType" @@ -1623,25 +1623,25 @@ parseChatMessage conn s = do errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) {-# INLINE parseChatMessage #-} -getChatScopeInfo :: VersionRangeChat -> User -> GroupChatScope -> CM GroupChatScopeInfo -getChatScopeInfo vr user = \case +getChatScopeInfo :: StoreCxt -> User -> GroupChatScope -> CM GroupChatScopeInfo +getChatScopeInfo cxt user = \case GCSMemberSupport Nothing -> pure $ GCSIMemberSupport Nothing GCSMemberSupport (Just gmId) -> do - supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId + supportMem <- withFastStore $ \db -> getGroupMemberById db cxt user gmId pure $ GCSIMemberSupport (Just supportMem) -getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] -getGroupRecipients vr user gInfo@GroupInfo {membership} scopeInfo modsCompatVersion +getGroupRecipients :: StoreCxt -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] +getGroupRecipients cxt user gInfo@GroupInfo {membership} scopeInfo modsCompatVersion | useRelays' gInfo && not (isRelay membership) = do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo | otherwise = case scopeInfo of Nothing -> do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + ms <- withFastStore' $ \db -> getGroupMembers db cxt user gInfo pure $ filter memberCurrent ms Just (GCSIMemberSupport Nothing) -> do - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" pure rcpModMs' @@ -1651,7 +1651,7 @@ getGroupRecipients vr user gInfo@GroupInfo {membership} scopeInfo modsCompatVers if memberStatus supportMem == GSMemPendingApproval then pure [supportMem] else do - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withFastStore' $ \db -> getGroupModerators db cxt user gInfo let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs pure $ [supportMem] <> rcpModMs' where @@ -1677,8 +1677,8 @@ mkGroupChatScope gInfo@GroupInfo {membership} m | otherwise = pure (gInfo, m, Nothing) -mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> MsgContent -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) -mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m mc msgScope_ = +mkGetMessageChatScope :: StoreCxt -> User -> GroupInfo -> GroupMember -> MsgContent -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGetMessageChatScope cxt user gInfo@GroupInfo {membership} m mc msgScope_ = mkGroupChatScope gInfo m >>= \case groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope (_, _, Nothing) @@ -1693,7 +1693,7 @@ mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m mc msgScope_ = (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo pure (gInfo', m, Just scopeInfo) | otherwise -> do - referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId + referredMember <- withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo mId -- TODO [knocking] return patched _referredMember'? (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember pure (gInfo, m, Just scopeInfo) @@ -1807,8 +1807,8 @@ cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, fil withStore' $ \db -> updateSndFileStatus db ft FSCancelled when sendCancel $ case fileInline of Just _ -> do - vr <- chatVersionRange - (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db vr user connId + cxt <- chatStoreCxt + (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db cxt user connId void $ sendDirectMessage_ conn (BFileChunk sharedMsgId FileChunkCancel) (ConnectionId connId) _ -> throwChatError $ CEException "cancelSndFileTransfer: cancelling file via a separate connection is deprecated" @@ -1992,13 +1992,13 @@ batchSndMessagesJSON mode = batchMessages mode maxEncodedMsgLength . L.toList encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString encodeConnInfo chatMsgEvent = do - vr <- chatVersionRange - encodeConnInfoPQ PQSupportOff (maxVersion vr) chatMsgEvent + cxt <- chatStoreCxt + encodeConnInfoPQ PQSupportOff (maxVersion (vr cxt)) chatMsgEvent encodeConnInfoPQ :: MsgEncodingI e => PQSupport -> VersionChat -> ChatMsgEvent e -> CM ByteString encodeConnInfoPQ pqSup v chatMsgEvent = do - vr <- chatVersionRange - let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent} + cxt <- chatStoreCxt + let info = ChatMessage {chatVRange = vr cxt, msgId = Nothing, chatMsgEvent} case encodeChatMessage maxEncodedInfoLength info of ECMEncoded connInfo -> case pqSup of PQSupportOn | v >= pqEncryptionCompressionVersion && B.length connInfo > maxCompressedInfoLength -> do @@ -2312,8 +2312,8 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) `catchAllErrors` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - vr <- chatVersionRange - fm <- withStore $ \db -> getGroupMember db vr user groupId forwardedByGroupMemberId + cxt <- chatStoreCxt + fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId forM_ (memberConn fm) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e @@ -2333,8 +2333,8 @@ saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMemb | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of (Just authorGMId, Nothing) -> do - vr <- chatVersionRange - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGMId + cxt <- chatStoreCxt + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ then forM_ (memberConn forwardingMember) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId @@ -2376,9 +2376,9 @@ saveSndChatItems :: CM [Either ChatError (ChatItem c 'MDSnd)] saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do createdAt <- liftIO getCurrentTime - vr <- chatVersionRange + cxt <- chatStoreCxt when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - void (withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing) + void (withStore' $ \db -> updateChatTsStats db cxt user cd createdAt Nothing) lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) @@ -2404,14 +2404,14 @@ ciContentNoParse content = (content, (ciContentToText content, Nothing)) saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime - vr <- chatVersionRange + cxt <- chatStoreCxt withStore' $ \db -> do (mentions' :: Map MemberName CIMention, userMention) <- case toChatInfo cd of GroupChat g@GroupInfo {membership} _ -> groupMentions db g membership _ -> pure (M.empty, False) cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) - then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) + then updateChatTsStats db cxt user cd createdAt (memberChatStats userMention) else pure $ toChatInfo cd let showAsGroup = case cd of CDChannelRcv {} -> True; _ -> False hasLink_ = ciContentHasLink content ft_ @@ -2704,13 +2704,13 @@ createChatItems :: createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ - vr <- chatVersionRange' - void . withStoreBatch' $ \db -> map (updateChat db vr createdAt) dirsCIContents + cxt <- chatStoreCxt' + void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents where - updateChat :: DB.Connection -> VersionRangeChat -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO () - updateChat db vr createdAt (cd, _, contents) - | any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt memberChatStats + updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO () + updateChat db cxt createdAt (cd, _, contents) + | any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats | otherwise = pure () where memberChatStats :: Maybe (Int, MemberAttention, Int) @@ -2749,8 +2749,8 @@ createLocalChatItems :: UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do - vr <- chatVersionRange - void $ withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing + cxt <- chatStoreCxt + void $ withStore' $ \db -> updateChatTsStats db cxt user cd createdAt Nothing (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) unless (null errs) $ toView $ CEvtChatErrors errs pure items @@ -2800,6 +2800,14 @@ waitChatStartedAndActivated = do activated <- readTVar chatActivated unless (isJust started && activated) retry +chatStoreCxt :: CM StoreCxt +chatStoreCxt = lift chatStoreCxt' +{-# INLINE chatStoreCxt #-} + +chatStoreCxt' :: CM' StoreCxt +chatStoreCxt' = mkStoreCxt <$> asks config +{-# INLINE chatStoreCxt' #-} + chatVersionRange :: CM VersionRangeChat chatVersionRange = lift chatVersionRange' {-# INLINE chatVersionRange #-} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 478c53c763..e25e665bea 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -115,10 +115,10 @@ processAgentMessage _ "" (ERR e) = processAgentMessage corrId connId msg = do lockEntity <- critical connId (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do - vr <- chatVersionRange + cxt <- chatStoreCxt -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here critical connId (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchAllErrors` eToView + Just user -> processAgentMessageConn cxt user corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. @@ -169,27 +169,27 @@ processAgentMsgSndFile _corrId aFileId msg = do process :: User -> FileTransferId -> CM () process user fileId = do (ft@FileTransferMeta {xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> getSndFileTransfer db user fileId - vr <- chatVersionRange + cxt <- chatStoreCxt unless cancelled $ case msg of SFPROG sndProgress sndTotal -> do let status = CIFSSndTransfer {sndProgress, sndTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtSndFileProgressXFTP user ci ft sndProgress sndTotal SFDONE sndDescr rfds -> do withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) - ci <- withStore $ \db -> lookupChatItemByFileId db vr user fileId + ci <- withStore $ \db -> lookupChatItemByFileId db cxt user fileId case ci of Nothing -> do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText rfds) case rfds of - [] -> sendFileError (FileErrOther "no receiver descriptions") "no receiver descriptions" vr ft + [] -> sendFileError (FileErrOther "no receiver descriptions") "no receiver descriptions" cxt ft rfd : _ -> case [fd | fd@(FD.ValidFileDescription FD.FileDescription {chunks = [_]}) <- rfds] of [] -> case xftpRedirectFor of Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CEvtSndFileRedirectStartXFTP user ft - Just _ -> sendFileError (FileErrOther "chaining redirects") "Prohibit chaining redirects" vr ft + Just _ -> sendFileError (FileErrOther "chaining redirects") "Prohibit chaining redirects" cxt ft rfds' -> do -- we have 1 chunk - use it as URI whether it is redirect or not ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor @@ -222,13 +222,13 @@ processAgentMsgSndFile _corrId aFileId msg = do sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId ci' <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId CIFSSndComplete - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) toView $ CEvtSndFileCompleteXFTP user ci' ft where getRecipients - | useRelays' g = withStore' $ \db -> getGroupRelayMembers db vr user g - | otherwise = withStore' $ \db -> getGroupMembers db vr user g + | useRelays' g = withStore' $ \db -> getGroupRelayMembers db cxt user g + | otherwise = withStore' $ \db -> getGroupMembers db cxt user g memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') where @@ -241,10 +241,10 @@ processAgentMsgSndFile _corrId aFileId msg = do logWarn $ "Sent file warning: " <> err ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSSndWarning $ agentFileError e) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtSndFileWarning user ci ft err SFERR e -> - sendFileError (agentFileError e) (tshow e) vr ft + sendFileError (agentFileError e) (tshow e) cxt ft where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode @@ -269,12 +269,12 @@ processAgentMsgSndFile _corrId aFileId msg = do toMsgReq :: (Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq toMsgReq (conn, _) SndMessage {msgId, msgBody} = (conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId])) - sendFileError :: FileError -> Text -> VersionRangeChat -> FileTransferMeta -> CM () - sendFileError ferr err vr ft = do + sendFileError :: FileError -> Text -> StoreCxt -> FileTransferMeta -> CM () + sendFileError ferr err cxt ft = do logError $ "Sent file error: " <> err ci <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId (CIFSSndError ferr) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) toView $ CEvtSndFileError user ci ft err @@ -309,13 +309,13 @@ processAgentMsgRcvFile _corrId aFileId msg = do process :: User -> FileTransferId -> CM () process user fileId = do ft <- withStore $ \db -> getRcvFileTransfer db user fileId - vr <- chatVersionRange + cxt <- chatStoreCxt unless (rcvFileCompleteOrCancelled ft) $ case msg of RFPROG rcvProgress rcvTotal -> do let status = CIFSRcvTransfer {rcvProgress, rcvTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtRcvFileProgressXFTP user ci rcvProgress rcvTotal ft RFDONE xftpPath -> case liveRcvFileTransferPath ft of @@ -327,13 +327,13 @@ processAgentMsgRcvFile _corrId aFileId msg = do liftIO $ do updateRcvFileStatus db fileId FSComplete updateCIFileStatus db user fileId CIFSRcvComplete - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId agentXFTPDeleteRcvFile aFileId fileId toView $ maybe (CEvtRcvStandaloneFileComplete user fsTargetPath ft) (CEvtRcvFileComplete user) ci_ RFWARN e -> do ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId (CIFSRcvWarning $ agentFileError e) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId toView $ CEvtRcvFileWarning user ci e ft RFERR e | e == FILE NOT_APPROVED -> do @@ -344,20 +344,20 @@ processAgentMsgRcvFile _corrId aFileId msg = do | otherwise -> do aci_ <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ agentFileError e) - lookupChatItemByFileId db vr user fileId + lookupChatItemByFileId db cxt user fileId forM_ aci_ cleanupACIFile agentXFTPDeleteRcvFile aFileId fileId toView $ CEvtRcvFileError user aci_ e ft type ShouldDeleteGroupConns = Bool -processAgentMessageConn :: VersionRangeChat -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () -processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do +processAgentMessageConn :: StoreCxt -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () +processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = do -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, -- as in this case no need to ACK message - we can't process messages for this connection anyway. -- SEDBException will be re-trown as CRITICAL as it is likely to indicate a temporary database condition -- that will be resolved with app restart. - entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus + entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db cxt user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct @@ -562,7 +562,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- XGrpLinkInv here means we are connecting via business contact card, so we replace contact with group (gInfo, host) <- withStore $ \db -> do liftIO $ deleteContactCardKeepConn db connId ct - createGroupInvitedViaLink db vr user conn'' glInv + createGroupInvitedViaLink db cxt user conn'' glInv void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) @@ -614,7 +614,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (connChatVersion < batchSend2Version) $ forM_ (autoReply $ addressSettings ucl) $ \mc -> sendAutoReply ct' mc Nothing -- old versions only -- TODO REMOVE LEGACY vvv forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId + groupInfo <- withStore $ \db -> getGroupInfo db cxt user groupId subMode <- chatReadVar subscriptionMode groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random @@ -727,7 +727,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] group link auto-accept continuation on receiving INV CFCreateConnGrpInv -> do (ct, groupLinkId) <- withStore $ \db -> do - ct <- getContactViaMember db vr user m + ct <- getContactViaMember db cxt user m liftIO $ setNewContactMemberConnRequest db user m cReq liftIO $ (ct,) <$> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId @@ -795,7 +795,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId), useRelays' gInfo == isJust rcvPG && pgId rcvPG == pgId curPG -> do -- XGrpLinkInv here means we are connecting via prepared group, and we have to update user and host member records - (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db vr user gInfo m glInv + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) @@ -803,7 +803,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do - (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersRejected db vr user gInfo m glRjct + (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersRejected db cxt user gInfo m glRjct toView $ CEvtGroupLinkConnecting user gInfo' m' toViewTE $ TEGroupLinkRejected user gInfo' rejectionReason _ -> messageError "CONF from host member in prepared group must have x.grp.link.inv or x.grp.link.reject" @@ -877,7 +877,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where firstConnectedHost | useRelays' gInfo = do - relayMems <- withStore' $ \db -> getGroupRelayMembers db vr user gInfo + relayMems <- withStore' $ \db -> getGroupRelayMembers db cxt user gInfo let numConnected = length $ filter (\GroupMember {memberStatus = ms} -> ms == GSMemConnected) relayMems pure $ numConnected == 1 | otherwise = pure True @@ -907,13 +907,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (connChatVersion < batchSend2Version) $ getAutoReplyMsg >>= mapM_ (\mc -> sendGroupAutoReply mc Nothing) if useRelays' gInfo'' then do - introduceInChannel vr user gInfo'' m' + introduceInChannel cxt user gInfo'' m' when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' else case mStatus of GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators vr user gInfo'' m' + GSMemPendingReview -> introduceToModerators cxt user gInfo'' m' _ -> do - introduceToAll vr user gInfo'' m' + introduceToAll cxt user gInfo'' m' let memberIsCustomer = case businessChat gInfo'' of Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId _ -> False @@ -936,12 +936,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemCon = \case GCPreMember -> forM_ (invitedByGroupMemberId membership) $ \hostId -> do - host <- withStore $ \db -> getGroupMember db vr user groupId hostId + host <- withStore $ \db -> getGroupMember db cxt user groupId hostId forM_ (memberConn host) $ \hostConn -> void $ sendDirectMemberMessage hostConn (XGrpMemCon memberId) groupId GCPostMember -> forM_ (invitedByGroupMemberId m) $ \invitingMemberId -> do - im <- withStore $ \db -> getGroupMember db vr user groupId invitingMemberId + im <- withStore $ \db -> getGroupMember db cxt user groupId invitingMemberId forM_ (memberConn im) $ \imConn -> void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" @@ -1202,7 +1202,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (confId, m', relay) <- withStore $ \db -> do confId <- getRelayConfId db m liftIO $ updateGroupMemberStatus db userId m GSMemAccepted - (m', relay) <- setRelayLinkAccepted db vr user m (MemberKey relayKey) relayProfile + (m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile pure (confId, m', relay) allowAgentConnectionAsync user conn confId XOk toView $ CEvtGroupRelayUpdated user gInfo m' relay @@ -1290,7 +1290,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = FileChunkCancel -> unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft - ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of @@ -1313,7 +1313,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateRcvFileStatus db fileId FSComplete updateCIFileStatus db user fileId CIFSRcvComplete deleteRcvFileChunks db ft - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileComplete user ci mapM_ (deleteAgentConnectionAsync . aConnId) conn_ RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () @@ -1338,7 +1338,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo (relays', changed, newlyActive) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays @@ -1351,7 +1351,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- dedicated subscriber count). when (fromMaybe 0 publicMemberCount > 1) $ forM_ (L.nonEmpty newlyActiveLinks) $ \newlyActive -> do - allRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + allRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db cxt user gInfo let recipients = filter (\GroupMember {memberStatus, relayLink} -> @@ -1401,7 +1401,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = AddressSettings {autoAccept} = addressSettings isSimplexTeam = sameConnReqContact connReq adminContactReq gVar <- asks random - withStore (\db -> createOrUpdateContactRequest db gVar vr user uclId ucl isSimplexTeam invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ reqPQSup) >>= \case + withStore (\db -> createOrUpdateContactRequest db gVar cxt user uclId ucl isSimplexTeam invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ reqPQSup) >>= \case RSAcceptedRequest _ucr re -> case re of REContact ct -> -- TODO [short links] update request msg @@ -1533,7 +1533,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- ##### Group link join requests (don't create contact requests) ##### Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do -- TODO [short links] deduplicate request by xContactId? - gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId if useRelays' gInfo then messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)" else do @@ -1559,10 +1559,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config if rejected - then rejectRelayInvitationAsync user uclId vr groupRelayInv invId chatVRange initialDelay RRRRejoinRejected + then rejectRelayInvitationAsync user uclId cxt groupRelayInv invId chatVRange initialDelay RRRRejoinRejected else do (_gInfo, _ownerMember) <- withStore $ \db -> - createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited + createRelayRequestGroup db cxt user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited lift $ void $ getRelayRequestWorker True xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () xGrpRelayTest invId chatVRange challenge = do @@ -1577,7 +1577,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let chatV = chatVR `peerConnChatVersion` chatVRange (cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do - Connection {connId = testCId} <- createRelayTestConnection db vr user acId ConnAccepted chatV subMode + Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode liftIO $ setCommandConnId db user cmdId testCId -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) @@ -1586,7 +1586,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey) (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing @@ -1756,7 +1756,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- sendProbe -> sendProbeHashes (currently) -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db vr user ct) + ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db cxt user ct) sendProbeHashes ms probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where @@ -1772,7 +1772,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ COMGroupMember m sendProbe probe - cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db vr user m) + cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db cxt user m) sendProbeHashes cs probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where @@ -1845,7 +1845,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageFileDescription Contact {contactId} sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getFileIdBySharedMsgId db userId contactId sharedMsgId - aci <- getChatItemByFileId db vr user fileId + aci <- getChatItemByFileId db cxt user fileId pure (fileId, aci) processFDMessage fileId aci fileDescr @@ -1853,7 +1853,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - aci <- getChatItemByFileId db vr user fileId + aci <- getChatItemByFileId db cxt user fileId pure (fileId, aci) case aci of AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} @@ -2014,7 +2014,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = cci <- case itemMemberId of Just itemMemberId' -> getGroupMemberCIBySharedMsgId db user g itemMemberId' sharedMsgId Nothing -> getGroupChatItemBySharedMsgId db user g Nothing sharedMsgId - scopeInfo <- getGroupChatScopeInfoForItem db vr user g (cChatItemId cci) + scopeInfo <- getGroupChatScopeInfoForItem db cxt user g (cChatItemId cci) pure (cci, scopeInfo) if ciReactionAllowed ci then do @@ -2052,13 +2052,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- no delivery task - message already forwarded by relay pure Nothing Just m@GroupMember {memberId} -> do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m content msgScope_ if blockedByAdmin m' then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing Nothing -> - withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case + withStore' (\db -> getCIModeration db cxt user gInfo' memberId sharedMsgId_) >>= \case Just ciModeration -> do applyModeration gInfo' m' scopeInfo ciModeration withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ @@ -2148,7 +2148,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else case m_ of Just m -> do let mentions' = if memberBlocked m then [] else mentions - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope cxt user gInfo m mc msgScope_ pure (gInfo', CDGroupRcv gInfo' scopeInfo m', mentions', scopeInfo) Nothing -> pure (gInfo, CDChannelRcv gInfo Nothing, mentions, Nothing) case m_ >>= \m -> prohibitedGroupContent gInfo' m scopeInfo mc ft_ (Nothing :: Maybe String) False of @@ -2179,7 +2179,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else case m_ of Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId - (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) + (cci,) <$> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) case cci of CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} | isSender m' -> updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') @@ -2291,7 +2291,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = a delete :: CChatItem 'CTGroup -> Bool -> Maybe GroupMember -> CM (Maybe DeliveryTaskContext) delete cci asGroup byGroupMember = do - scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) let fullDelete | asGroup = groupFeatureAllowed SGFFullDelete gInfo | otherwise = maybe False (\m -> groupFeatureMemberAllowed SGFFullDelete m gInfo) m_ @@ -2359,14 +2359,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (fileId,) <$> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft - ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId toView $ CEvtRcvFileSndCancelled user ci ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> CM () xFileAcptInv ct sharedMsgId fileConnReq_ fName = do (fileId, AChatItem _ _ _ ci) <- withStore $ \db -> do fileId <- getDirectFileIdBySharedMsgId db user ct sharedMsgId - (fileId,) <$> getChatItemByFileId db vr user fileId + (fileId,) <$> getChatItemByFileId db cxt user fileId assertSMPAcceptNotProhibited ci ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) -- [async agent commands] no continuation needed, but command should be asynchronous for stability @@ -2375,7 +2375,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- receiving inline Nothing -> do event <- withStore $ \db -> do - ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db cxt user fileId $ CIFSSndTransfer 0 1 sft <- createSndDirectInlineFT db ct ft pure $ CEvtSndFileStart user ci' sft toView event @@ -2403,7 +2403,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ sft_ $ \sft@SndFileTransfer {fileId} -> do ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> do liftIO $ updateSndFileStatus db sft FSComplete - updateDirectCIFileStatus db vr user fileId CIFSSndComplete + updateDirectCIFileStatus db cxt user fileId CIFSSndComplete case file of Just CIFile {fileProtocol = FPXFTP} -> do ft <- withStore $ \db -> getFileTransferMeta db user fileId @@ -2441,7 +2441,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - (fileId,) <$> getChatItemByFileId db vr user fileId + (fileId,) <$> getChatItemByFileId db cxt user fileId case aci of AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} | validSender m_ chatDir -> do @@ -2457,7 +2457,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xFileAcptInvGroup GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName = do (fileId, AChatItem _ _ _ ci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - (fileId,) <$> getChatItemByFileId db vr user fileId + (fileId,) <$> getChatItemByFileId db cxt user fileId assertSMPAcceptNotProhibited ci -- TODO check that it's not already accepted ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) @@ -2466,7 +2466,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (Nothing, Just conn) -> do -- receiving inline event <- withStore $ \db -> do - ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db cxt user fileId $ CIFSSndTransfer 0 1 sft <- liftIO $ createSndGroupInlineFT db m conn ft pure $ CEvtSndFileStart user ci' sft toView event @@ -2492,7 +2492,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile - (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db vr user ct inv customUserProfileId + (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership if sameGroupLinkId groupLinkId groupLinkId' @@ -2533,7 +2533,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do (ct', contactConns) <- withStore' $ \db -> do ct' <- updateContactStatus db user c CSDeleted - (ct',) <$> getContactConnections db vr userId ct' + (ct',) <$> getContactConnections db cxt userId ct' deleteAgentConnectionsAsync $ map aConnId contactConns forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} @@ -2542,7 +2542,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] toView $ CEvtContactDeletedByContact user ct'' else do - contactConns <- withStore' $ \db -> getContactConnections db vr userId c + contactConns <- withStore' $ \db -> getContactConnections db cxt userId c deleteAgentConnectionsAsync $ map aConnId contactConns withStore $ \db -> deleteContact db user c where @@ -2611,7 +2611,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.link.acpt with insufficient member permissions" | sameMemberId memberId membership = processUserAccepted | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memberId) >>= \case Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" Right referencedMember -> do (referencedMember', gInfo') <- withStore' $ \db -> do @@ -2655,7 +2655,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = GAPendingApproval -> messageWarning "x.grp.link.acpt: unexpected group acceptance - pending approval" introduceToRemainingMembers acceptedMember = do - introduceToRemaining vr user gInfo acceptedMember + introduceToRemaining cxt user gInfo acceptedMember when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember maybeCreateGroupDescrLocal :: GroupInfo -> GroupMember -> CM () @@ -2677,7 +2677,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do - mCt <- withStore $ \db -> getContact db vr user mContactId + mCt <- withStore $ \db -> getContact db cxt user mContactId if canUpdateProfile mCt then do (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' @@ -2725,7 +2725,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm2)) $ do - cgm1s <- withStore' $ \db -> matchReceivedProbe db vr user cgm2 probe + cgm1s <- withStore' $ \db -> matchReceivedProbe db cxt user cgm2 probe let cgm1s' = filter (not . contactOrMemberIncognito) cgm1s probeMatches cgm1s' cgm2 where @@ -2741,7 +2741,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do - cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db vr user cgm1 probeHash + cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db cxt user cgm1 probeHash forM_ cgm2Probe_ $ \(cgm2, probe) -> unless (contactOrMemberIncognito cgm2) . void $ probeMatch cgm1 cgm2 probe @@ -2771,7 +2771,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoProbeOk :: ContactOrMember -> Probe -> CM () xInfoProbeOk cgm1 probe = do - cgm2 <- withStore' $ \db -> matchSentProbe db vr user cgm1 probe + cgm2 <- withStore' $ \db -> matchSentProbe db cxt user cgm1 probe case cgm1 of COMContact c1 -> case cgm2 of @@ -2920,14 +2920,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = associateMemberWithContact c1 m2@GroupMember {groupId} = do g <- withStore $ \db -> do liftIO $ associateMemberWithContactRecord db user c1 m2 - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId toView $ CEvtContactAndMemberAssociated user c1 g m2 c1 pure c1 associateContactWithMember :: GroupMember -> Contact -> CM Contact associateContactWithMember m1@GroupMember {groupId} c2 = do (c2', g) <- withStore $ \db -> - liftM2 (,) (associateContactWithMemberRecord db vr user m1 c2) (getGroupInfo db vr user groupId) + liftM2 (,) (associateContactWithMemberRecord db cxt user m1 c2) (getGroupInfo db cxt user groupId) toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' @@ -2937,15 +2937,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - ct <- withStore $ \db -> createDirectContact db vr user conn' p + ct <- withStore $ \db -> createDirectContact db cxt user conn' p toView $ CEvtContactConnecting user ct pure (conn', Nothing) XGrpLinkInv glInv -> do - (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv + (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db cxt user conn' glInv toView $ CEvtGroupLinkConnecting user gInfo host pure (conn', Just gInfo) XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do - (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct + (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db cxt user conn' glRjct toView $ CEvtGroupLinkConnecting user gInfo host toViewTE $ TEGroupLinkRejected user gInfo rejectionReason pure (conn', Just gInfo) @@ -2958,10 +2958,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if sameMemberId memId (membership gInfo) then pure Nothing else do - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do (updatedMember, gInfo') <- withStore $ \db -> do - updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus + updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus gInfo' <- if memberPending updatedMember then liftIO $ increaseGroupMembersRequireAttention db user gInfo @@ -3014,10 +3014,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _ _) memRestrictions = do case memberCategory m of GCHostMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Right existingMember | useRelays' gInfo -> do - updatedMember <- withStore $ \db -> updatePreparedChannelMember db vr user existingMember memInfo + updatedMember <- withStore $ \db -> updatePreparedChannelMember db cxt user existingMember memInfo toView $ CEvtGroupMemberUpdated user gInfo existingMember updatedMember | otherwise -> messageError "x.grp.mem.intro ignored: member already exists" @@ -3038,7 +3038,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = subMode <- chatReadVar subscriptionMode -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second groupConnIds <- createConn subMode - let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> do reMember <- createIntroReMember db user gInfo memInfo memRestrictions createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode @@ -3049,7 +3049,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do - hostConn <- withStore $ \db -> getConnectionById db vr user hostConnId + hostConn <- withStore $ \db -> getConnectionById db cxt user hostConnId let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq} void $ sendDirectMemberMessage hostConn msg groupId withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited @@ -3058,7 +3058,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemInv gInfo m memId introInv = do case memberCategory m of GCInviteeMember -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> sendGroupMemberMessage gInfo reMember $ XGrpMemFwd (memberInfo gInfo m) introInv _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -3069,7 +3069,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkHostRole m memRole toMember <- withStore $ \db -> do toMember <- - getGroupMemberByMemberId db vr user gInfo memId + getGroupMemberByMemberId db cxt user gInfo memId -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent -- the situation when member does not exist is an error -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. @@ -3093,7 +3093,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user Nothing True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memChatVRange - chatV = vr `peerConnChatVersion` mcvr + chatV = vr cxt `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) @@ -3102,7 +3102,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let gInfo' = gInfo {membership = membership {memberRole = memRole}} in changeMemberRole gInfo' membership $ RGEUserRole memRole | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole Left _ -> messageError "x.grp.mem.role with unknown member ID" $> Nothing where @@ -3133,7 +3133,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency | otherwise = do unknownRole <- unknownMemberRole gInfo - withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memId "" unknownRole True) >>= \case + withStore (\db -> getCreateUnknownGMByMemberId db cxt user gInfo memId "" unknownRole True) >>= \case Nothing -> messageError "x.grp.mem.restrict: no member" $> Nothing -- shouldn't happen Just (bm, unknown) -> do let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm @@ -3157,7 +3157,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () xGrpMemCon gInfo sendingMem memId = do - refMem <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId + refMem <- withStore $ \db -> getGroupMemberByMemberId db cxt user gInfo memId -- Updating vectors in separate transactions to avoid deadlocks. withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected @@ -3179,7 +3179,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} else - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> do messageError "x.grp.mem.del with unknown member ID" pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} @@ -3323,7 +3323,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberContactId of Nothing -> createNewContact subMode Just mContactId -> do - mCt <- withStore $ \db -> getContact db vr user mContactId + mCt <- withStore $ \db -> getContact db cxt user mContactId let Contact {activeConn, contactGrpInvSent} = mCt forM_ activeConn $ \Connection {connId} -> if contactGrpInvSent @@ -3350,7 +3350,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mCt' <- withStore $ \db -> do updateMemberContactInvited db user mCt groupDirectInv void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode - getContact db vr user mContactId + getContact db cxt user mContactId securityCodeChanged mCt' createItems mCt' m | otherwise = do @@ -3358,7 +3358,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mCt' <- withStore $ \db -> do updateMemberContactInvited db user mCt groupDirectInv void $ liftIO $ createMemberContactConn db user acId Nothing g mConn ConnPrepared mContactId subMode - getContact db vr user mContactId + getContact db cxt user mContactId securityCodeChanged mCt' createInternalChatItem user (CDDirectRcv mCt') (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing createItems mCt' m @@ -3369,7 +3369,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (mCt, m') <- withStore $ \db -> do (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode - mCt <- getContact db vr user mContactId + mCt <- getContact db cxt user mContactId pure (mCt, m') createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) createItems mCt m' @@ -3378,7 +3378,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (mCt, m') <- withStore $ \db -> do (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv void $ liftIO $ createMemberContactConn db user acId Nothing g mConn ConnPrepared mContactId subMode - mCt <- getContact db vr user mContactId + mCt <- getContact db cxt user mContactId pure (mCt, m') createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) createInternalChatItem user (CDDirectRcv mCt) (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing @@ -3409,7 +3409,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = FwdMember memberId memberName -> do unknownRole <- unknownMemberRole gInfo let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_ - withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownRole allowCreate) >>= \case + withStore (\db -> getCreateUnknownGMByMemberId db cxt user gInfo memberId memberName unknownRole allowCreate) >>= \case Just (author, unknown) -> do when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author void $ withVerifiedMsg gInfo scopeInfo author parsedMsg msgTs $ @@ -3536,7 +3536,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- SENT and RCVD events are received for messages that may be batched in single scope, -- so we can look up scope of first item scopeInfo <- case cis of - (ci : _) -> getGroupChatScopeInfoForItem db vr user gInfo (chatItemId' ci) + (ci : _) -> getGroupChatScopeInfoForItem db cxt user gInfo (chatItemId' ci) _ -> pure Nothing pure $ map (gItem scopeInfo) cis unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis @@ -3560,14 +3560,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteGroupConnections :: User -> GroupInfo -> Bool -> CM () deleteGroupConnections user gInfo@GroupInfo {membership} waitDelivery = do - vr <- chatVersionRange + cxt <- chatStoreCxt -- member records are not deleted to keep history - members <- getMembers vr + members <- getMembers cxt deleteMembersConnections' user members waitDelivery where - getMembers vr - | useRelays' gInfo, not (isRelay membership) = withStore' $ \db -> getGroupRelayMembers db vr user gInfo - | otherwise = withStore' $ \db -> getGroupMembers db vr user gInfo + getMembers cxt + | useRelays' gInfo, not (isRelay membership) = withStore' $ \db -> getGroupRelayMembers db cxt user gInfo + | otherwise = withStore' $ \db -> getGroupMembers db cxt user gInfo startDeliveryTaskWorkers :: CM () startDeliveryTaskWorkers = do @@ -3587,20 +3587,20 @@ getDeliveryTaskWorker hasWork deliveryKey = do runDeliveryTaskWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryTaskWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config - vr <- chatVersionRange + cxt <- chatStoreCxt -- TODO [relays] in future may be required to read groupInfo and user on each iteration for up to date state -- TODO - same for delivery jobs (runDeliveryJobWorker) gInfo <- withStore $ \db -> do user <- getUserByGroupId db groupId - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId forever $ do unless (delay == 0) $ liftIO $ threadDelay' delay lift $ waitForWork doWork - runDeliveryTaskOperation vr gInfo + runDeliveryTaskOperation cxt gInfo where (groupId, workerScope) = deliveryKey - runDeliveryTaskOperation :: VersionRangeChat -> GroupInfo -> CM () - runDeliveryTaskOperation vr gInfo = do + runDeliveryTaskOperation :: StoreCxt -> GroupInfo -> CM () + runDeliveryTaskOperation cxt gInfo = do withWork_ a doWork (withStore' $ \db -> getNextDeliveryTask db deliveryKey) $ \task -> processDeliveryTask task `catchAllErrors` \e -> do @@ -3616,7 +3616,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks withStore' $ \db -> do createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed @@ -3658,19 +3658,19 @@ getDeliveryJobWorker hasWork deliveryKey = do runDeliveryJobWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryJobWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config - vr <- chatVersionRange + cxt <- chatStoreCxt (user, gInfo) <- withStore $ \db -> do user <- getUserByGroupId db groupId - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId pure (user, gInfo) forever $ do unless (delay == 0) $ liftIO $ threadDelay' delay lift $ waitForWork doWork - runDeliveryJobOperation vr user gInfo + runDeliveryJobOperation cxt user gInfo where (groupId, workerScope) = deliveryKey - runDeliveryJobOperation :: VersionRangeChat -> User -> GroupInfo -> CM () - runDeliveryJobOperation vr user gInfo = do + runDeliveryJobOperation :: StoreCxt -> User -> GroupInfo -> CM () + runDeliveryJobOperation cxt user gInfo = do withWork_ a doWork (withStore' $ \db -> getNextDeliveryJob db deliveryKey) $ \job -> processDeliveryJob job `catchAllErrors` \e -> do @@ -3708,7 +3708,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do where sendLoop :: Int -> Maybe GroupMemberId -> CM () sendLoop bucketSize cursorGMId_ = do - mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize + mems <- withStore' $ \db -> getGroupMembersByCursor db cxt user gInfo cursorGMId_ singleSenderGMId_ bucketSize unless (null mems) $ do deliver body mems let cursorGMId' = groupMemberId' $ last mems @@ -3716,7 +3716,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') DJSMemberSupport scopeGMId -> do -- for member support scope we just load all recipients in one go, without cursor - modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo let moderatorFilter m = memberCurrent m && maxVersion (memberChatVRange m) >= groupKnockingVersion @@ -3726,14 +3726,14 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do if Just scopeGMId == singleSenderGMId_ then pure modMs' else do - scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId + scopeMem <- withStore $ \db -> getGroupMemberById db cxt user scopeGMId pure $ scopeMem : modMs' unless (null mems) $ deliver body mems -- fully connected group | otherwise = case singleSenderGMId_ of Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" Just singleSenderGMId -> do - sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId + sender <- withStore $ \db -> getGroupMemberById db cxt user singleSenderGMId ms <- buildMemberList sender unless (null ms) $ deliver body ms where @@ -3743,14 +3743,14 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec case jobScope of DJSGroup {jobSpec} -> do - ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs + ms <- withStore' $ \db -> getGroupMembersByIndexes db cxt user gInfo introducedMemsIdxs pure $ filter shouldForwardTo ms where shouldForwardTo m | jobSpecImpliedPending jobSpec = memberCurrentOrPending m | otherwise = memberCurrent m DJSMemberSupport scopeGMId -> do - ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs + ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId introducedMemsIdxs pure $ filter shouldForwardTo ms where shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m @@ -3801,7 +3801,7 @@ getRelayRequestWorker hasWork = do runRelayRequestWorker :: AgentClient -> Worker -> CM () runRelayRequestWorker a Worker {doWork} = do - vr <- chatVersionRange + cxt <- chatStoreCxt (user, uclId) <- withStore $ \db -> do user <- getRelayUser db UserContactLink {userContactLinkId} <- getUserAddress db user @@ -3809,10 +3809,10 @@ runRelayRequestWorker a Worker {doWork} = do delayThreads <- liftIO TM.emptyIO forever $ do lift $ waitForWork doWork - runRelayRequestOperation delayThreads vr user uclId + runRelayRequestOperation delayThreads cxt user uclId where - runRelayRequestOperation :: TM.TMap GroupId (TMVar (Weak ThreadId)) -> VersionRangeChat -> User -> Int64 -> CM () - runRelayRequestOperation delayThreads vr user uclId = + runRelayRequestOperation :: TM.TMap GroupId (TMVar (Weak ThreadId)) -> StoreCxt -> User -> Int64 -> CM () + runRelayRequestOperation delayThreads cxt user uclId = withWork_ a doWork getReadyRelayRequest $ \(groupId, rrd) -> do ChatConfig {relayRequestExpiry} <- asks config @@ -3861,7 +3861,7 @@ runRelayRequestWorker a Worker {doWork} = do processRelayRequest :: GroupId -> RelayRequestData -> CM () processRelayRequest groupId rrd = do (gInfo, groupLink_) <- withStore $ \db -> do - gInfo <- getGroupInfo db vr user groupId + gInfo <- getGroupInfo db cxt user groupId groupLink_ <- liftIO $ runExceptT $ getGroupLink db user gInfo pure (gInfo, groupLink_) -- Check if relay link already exists (recovery case) @@ -3889,7 +3889,7 @@ runRelayRequestWorker a Worker {doWork} = do gInfo' <- withStore $ \db -> do void $ updateGroupProfile db user gInfo gp updateRelayGroupKeys db user gInfo pg rootKey memberPrivKey owners - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId pure (gInfo', sLnk) where validateGroupProfile :: GroupProfile -> CM () @@ -3921,5 +3921,5 @@ runRelayRequestWorker a Worker {doWork} = do pure (sigKeys, sLnk) acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do - ownerMember <- withStore $ \db -> getHostMember db vr user groupId + ownerMember <- withStore $ \db -> getHostMember db cxt user groupId void $ acceptRelayJoinRequestAsync user uclId gi ownerMember relayInvId reqChatVRange relayLink diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 60d865cb30..abc40f1e6e 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -74,8 +74,8 @@ getChatLockEntity db agentConnId = do -- TODO consider whether ConnFailed connections should be excluded: -- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash -- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub -getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity -getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do +getConnectionEntity :: DB.Connection -> StoreCxt -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity +getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ case entityId of Nothing -> @@ -90,7 +90,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do where getConnection_ :: ExceptT StoreError IO Connection getConnection_ = ExceptT $ do - firstRow (toConnection vr) (SEConnectionNotFound agentConnId) $ + firstRow (toConnection cxt) (SEConnectionNotFound agentConnId) $ DB.query db [sql| @@ -172,7 +172,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do liftIO $ bitraverse (addGroupChatTags db) pure gm toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) toGroupAndMember c (groupInfoRow :. memberRow) = - let groupInfo = toGroupInfo vr userContactId [] groupInfoRow + let groupInfo = toGroupInfo cxt userContactId [] groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact @@ -191,17 +191,17 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound -getConnectionEntityByConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) -getConnectionEntityByConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do +getConnectionEntityByConnReq :: DB.Connection -> StoreCxt -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do connId_ <- maybeFirstRow fromOnly $ 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 vr user) connId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db cxt user) connId_ -getConnectionEntityViaShortLink :: DB.Connection -> VersionRangeChat -> User -> ShortLinkInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity)) -getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do +getConnectionEntityViaShortLink :: DB.Connection -> StoreCxt -> User -> ShortLinkInvitation -> IO (Maybe (ConnReqInvitation, ConnectionEntity)) +getConnectionEntityViaShortLink db cxt user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do (cReq, connId) <- ExceptT getConnReqConnId - (cReq,) <$> getConnectionEntity db vr user connId + (cReq,) <$> getConnectionEntity db cxt user connId where getConnReqConnId = firstRow' toConnReqConnId (SEInternalError "connection not found") $ @@ -222,8 +222,8 @@ getConnectionEntityViaShortLink db vr user@User {userId} shortLink = fmap either -- 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 -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) -getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2) = do +getContactConnEntityByConnReqHash :: DB.Connection -> StoreCxt -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash db cxt user@User {userId} (cReqHash1, cReqHash2) = do connId_ <- maybeFirstRow fromOnly $ DB.query @@ -240,7 +240,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 ) c |] (userId, cReqHash1, cReqHash2, ConnDeleted) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db cxt user) connId_ getContactConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] getContactConnsToSub db User {userId} filterToSubscribe = diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 1e0ca8bdc5..27cb970b73 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -49,7 +49,7 @@ import Database.SQLite.Simple.QQ (sql) createOrUpdateContactRequest :: DB.Connection -> TVar ChaChaDRG -> - VersionRangeChat -> + StoreCxt -> User -> Int64 -> UserContactLink -> @@ -65,7 +65,7 @@ createOrUpdateContactRequest :: createOrUpdateContactRequest db gVar - vr + cxt user@User {userId, userContactId} uclId UserContactLink {addressSettings = AddressSettings {businessAddress}} @@ -89,7 +89,7 @@ createOrUpdateContactRequest Nothing -> liftIO (getAcceptedBusinessChat xContactId) >>= \case Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId cr <- liftIO $ getContactRequestByXContactId xContactId pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest @@ -104,7 +104,7 @@ createOrUpdateContactRequest getAcceptedContact :: XContactId -> IO (Maybe Contact) getAcceptedContact xContactId = do ct_ <- - maybeFirstRow (toContact vr user []) $ + maybeFirstRow (toContact cxt user []) $ DB.query db [sql| @@ -128,7 +128,7 @@ createOrUpdateContactRequest getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) getAcceptedBusinessChat xContactId = do g_ <- - maybeFirstRow (toGroupInfo vr userContactId []) $ + maybeFirstRow (toGroupInfo cxt userContactId []) $ DB.query db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") @@ -200,12 +200,12 @@ createOrUpdateContactRequest "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" (contactId, contactRequestId) ucr <- getContactRequest db user contactRequestId - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) createBusinessChat = do let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user (gInfo@GroupInfo {groupId}, clientMember) <- - createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences + createBusinessRequestGroup db cxt gVar user cReqChatVRange profile profileId ldn groupPreferences liftIO $ DB.execute db @@ -278,13 +278,13 @@ createOrUpdateContactRequest getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = case (contactId_, businessGroupId_) of (Just contactId, Nothing) -> do - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId pure $ Just (REContact ct) (Nothing, Just businessGroupId) -> do - gInfo <- getGroupInfo db vr user businessGroupId + gInfo <- getGroupInfo db cxt user businessGroupId case gInfo of GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId pure $ Just (REBusinessChat gInfo clientMember) _ -> throwError SEInvalidBusinessChatContactRequest (Nothing, Nothing) -> pure Nothing diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 393e008835..e60d51ac85 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -332,8 +332,8 @@ updateDeliveryJobStatus_ db jobId status errReason_ = do (status, errReason_, currentTs, jobId) -- TODO [relays] possible improvement is to prioritize owners and "active" members -getGroupMembersByCursor :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] -getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do +getGroupMembersByCursor :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] +getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do gmIds :: [Int64] <- map fromOnly <$> case cursorGMId_ of Nothing -> @@ -351,13 +351,13 @@ getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} curs :. (cursorGMId, count) ) #if defined(dbPostgres) - map (toContactMember vr user) <$> + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_member_id IN ?") (Only (In gmIds)) #else - rights <$> mapM (runExceptT . getGroupMemberById db vr user) gmIds + rights <$> mapM (runExceptT . getGroupMemberById db cxt user) gmIds #endif where query = diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 60f898e52e..1c2f35f2bf 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -243,8 +243,8 @@ createRelayMemberConnectionAsync db user@User {userId} gInfo GroupMember {groupM where customUserProfileId_ = localProfileId <$> incognitoMembershipProfile gInfo -createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection -createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do +createRelayTestConnection :: DB.Connection -> StoreCxt -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db cxt user@User {userId} agentConnId connStatus chatV subMode = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -261,7 +261,7 @@ createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV :. (BI True, currentTs, currentTs) ) connId <- liftIO $ insertedRowId db - getConnectionById db vr user connId + getConnectionById db cxt user connId updateConnLinkData :: DB.Connection -> User -> Connection -> ConnReqContact -> ConnReqUriHash -> Maybe GroupLinkId -> VersionChat -> PQSupport -> IO () updateConnLinkData db User {userId} Connection {connId} cReq cReqHash groupLinkId_ chatV pqSup = do @@ -285,13 +285,13 @@ setPreparedGroupStartedConnection db groupId = do "UPDATE groups SET conn_link_started_connection = ?, updated_at = ? WHERE group_id = ?" (BI True, currentTs, groupId) -getConnReqContactXContactId :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Either (Maybe Connection) Contact) -getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 = - getContactByConnReqHash db vr user cReqHash1 cReqHash2 >>= maybe (Left <$> getConnection) (pure . Right) +getConnReqContactXContactId :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Either (Maybe Connection) Contact) +getConnReqContactXContactId db cxt user@User {userId} cReqHash1 cReqHash2 = + getContactByConnReqHash db cxt user cReqHash1 cReqHash2 >>= maybe (Left <$> getConnection) (pure . Right) where getConnection :: IO (Maybe Connection) getConnection = - maybeFirstRow (toConnection vr) $ + maybeFirstRow (toConnection cxt) $ DB.query db [sql| @@ -305,10 +305,10 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 = |] (userId, cReqHash1, userId, cReqHash2) -getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) -getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do +getContactByConnReqHash :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) +getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do ct <- - maybeFirstRow (toContact vr user []) $ + maybeFirstRow (toContact cxt user []) $ DB.query db [sql| @@ -394,18 +394,18 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createPreparedContact :: DB.Connection -> VersionRangeChat -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> ExceptT StoreError IO Contact -createPreparedContact db vr user p connLinkToConnect welcomeSharedMsgId = do +createPreparedContact :: DB.Connection -> StoreCxt -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> ExceptT StoreError IO Contact +createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs - getContact db vr user contactId + getContact db cxt user contactId -updatePreparedContactUser :: DB.Connection -> VersionRangeChat -> User -> Contact -> User -> ExceptT StoreError IO Contact +updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact updatePreparedContactUser db - vr + cxt user Contact {contactId, localDisplayName = oldLDN, profile = profile@LocalProfile {profileId, displayName}} newUser@User {userId = newUserId} = do @@ -438,15 +438,15 @@ updatePreparedContactUser |] (newUserId, currentTs, contactId) safeDeleteLDN db user oldLDN - getContact db vr newUser contactId + getContact db cxt newUser contactId -createDirectContact :: DB.Connection -> VersionRangeChat -> User -> Connection -> Profile -> ExceptT StoreError IO Contact -createDirectContact db vr user Connection {connId, localAlias} p = do +createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profile -> ExceptT StoreError IO Contact +createDirectContact db cxt user Connection {connId, localAlias} p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) - getContact db vr user contactId + getContact db cxt user contactId deleteContactConnections :: DB.Connection -> User -> Contact -> IO () deleteContactConnections db User {userId} Contact {contactId} = do @@ -500,13 +500,13 @@ deleteContactWithoutGroups db user@User {userId} ct@Contact {contactId, localDis deleteUnusedIncognitoProfileById_ db user profileId -- TODO remove in future versions: only used for legacy contact cleanup -getDeletedContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact] -getDeletedContacts db vr user@User {userId} = do +getDeletedContacts :: DB.Connection -> StoreCxt -> User -> IO [Contact] +getDeletedContacts db cxt user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 1" (Only userId) - rights <$> mapM (runExceptT . getDeletedContact db vr user) contactIds + rights <$> mapM (runExceptT . getDeletedContact db cxt user) contactIds -getDeletedContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Contact -getDeletedContact db vr user contactId = getContact_ db vr user contactId True +getDeletedContact :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Contact +getDeletedContact db cxt user contactId = getContact_ db cxt user contactId True deleteContactProfile_ :: DB.Connection -> UserId -> ContactId -> IO () deleteContactProfile_ db userId contactId = @@ -756,15 +756,15 @@ updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt (newName, updatedAt, userId, contactId) safeDeleteLDN db user displayName -getContactByName :: DB.Connection -> VersionRangeChat -> User -> ContactName -> ExceptT StoreError IO Contact -getContactByName db vr user localDisplayName = do +getContactByName :: DB.Connection -> StoreCxt -> User -> ContactName -> ExceptT StoreError IO Contact +getContactByName db cxt user localDisplayName = do cId <- getContactIdByName db user localDisplayName - getContact db vr user cId + getContact db cxt user cId -getUserContacts :: DB.Connection -> VersionRangeChat -> User -> IO [Contact] -getUserContacts db vr user@User {userId} = do +getUserContacts :: DB.Connection -> StoreCxt -> User -> IO [Contact] +getUserContacts db cxt user@User {userId} = do contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId) - contacts <- rights <$> mapM (runExceptT . getContact db vr user) contactIds + contacts <- rights <$> mapM (runExceptT . getContact db cxt user) contactIds pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts getUserContactLinkIdByCReq :: DB.Connection -> Int64 -> ExceptT StoreError IO (Maybe Int64) @@ -890,22 +890,22 @@ getContactIdByName db User {userId} cName = ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0" (userId, cName) -getContactViaShortLinkToConnect :: forall c. ConnectionModeI c => DB.Connection -> VersionRangeChat -> User -> ConnShortLink c -> ExceptT StoreError IO (Maybe (ConnectionRequestUri c, Contact)) -getContactViaShortLinkToConnect db vr user@User {userId} shortLink = do +getContactViaShortLinkToConnect :: forall c. ConnectionModeI c => DB.Connection -> StoreCxt -> User -> ConnShortLink c -> ExceptT StoreError IO (Maybe (ConnectionRequestUri c, Contact)) +getContactViaShortLinkToConnect db cxt user@User {userId} shortLink = do liftIO (maybeFirstRow id $ DB.query db "SELECT contact_id, conn_full_link_to_connect FROM contacts WHERE user_id = ? AND conn_short_link_to_connect = ?" (userId, shortLink)) >>= \case Just (ctId :: Int64, Just (ACR cMode cReq)) -> case testEquality cMode (sConnectionMode @c) of - Just Refl -> Just . (cReq,) <$> getContact db vr user ctId + Just Refl -> Just . (cReq,) <$> getContact db cxt user ctId Nothing -> pure Nothing _ -> pure Nothing -getContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Contact -getContact db vr user contactId = getContact_ db vr user contactId False +getContact :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Contact +getContact db cxt user contactId = getContact_ db cxt user contactId False -getContact_ :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact -getContact_ db vr user@User {userId} contactId deleted = do +getContact_ :: DB.Connection -> StoreCxt -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact +getContact_ db cxt user@User {userId} contactId deleted = do chatTags <- liftIO $ getDirectChatTags db contactId - ExceptT . firstRow (toContact vr user chatTags) (SEContactNotFound contactId) $ + ExceptT . firstRow (toContact cxt user chatTags) (SEContactNotFound contactId) $ DB.query db [sql| @@ -932,8 +932,8 @@ getUserByContactRequestId db contactRequestId = ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) -getContactConnections :: DB.Connection -> VersionRangeChat -> UserId -> Contact -> IO [Connection] -getContactConnections db vr userId Contact {contactId} = +getContactConnections :: DB.Connection -> StoreCxt -> UserId -> Contact -> IO [Connection] +getContactConnections db cxt userId Contact {contactId} = connections =<< liftIO getConnections_ where getConnections_ = @@ -950,11 +950,11 @@ getContactConnections db vr userId Contact {contactId} = |] (userId, userId, contactId) connections [] = pure [] - connections rows = pure $ map (toConnection vr) rows + connections rows = pure $ map (toConnection cxt) rows -getConnectionById :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Connection -getConnectionById db vr User {userId} connId = ExceptT $ do - firstRow (toConnection vr) (SEConnectionNotFoundById connId) $ +getConnectionById :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO Connection +getConnectionById db cxt User {userId} connId = ExceptT $ do + firstRow (toConnection cxt) (SEConnectionNotFoundById connId) $ DB.query db [sql| diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 951fce8958..5289a3b304 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -570,19 +570,19 @@ getRcvFileTransfer_ db userId fileId = do Just fp -> pure fp cancelled = maybe False unBI cancelled_ -acceptRcvInlineFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem -acceptRcvInlineFT db vr user fileId filePath = do +acceptRcvInlineFT :: DB.Connection -> StoreCxt -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem +acceptRcvInlineFT db cxt user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath False (Just IFMOffer) =<< getCurrentTime - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId startRcvInlineFT :: DB.Connection -> User -> RcvFileTransfer -> FilePath -> Maybe InlineFileMode -> IO () startRcvInlineFT db user RcvFileTransfer {fileId} filePath rcvFileInline = acceptRcvFT_ db user fileId filePath False rcvFileInline =<< getCurrentTime -xftpAcceptRcvFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> Bool -> ExceptT StoreError IO AChatItem -xftpAcceptRcvFT db vr user fileId filePath userApprovedRelays = do +xftpAcceptRcvFT :: DB.Connection -> StoreCxt -> User -> FileTransferId -> FilePath -> Bool -> ExceptT StoreError IO AChatItem +xftpAcceptRcvFT db cxt user fileId filePath userApprovedRelays = do liftIO $ acceptRcvFT_ db user fileId filePath userApprovedRelays Nothing =<< getCurrentTime - getChatItemByFileId db vr user fileId + getChatItemByFileId db cxt user fileId acceptRcvFT_ :: DB.Connection -> User -> FileTransferId -> FilePath -> Bool -> Maybe InlineFileMode -> UTCTime -> IO () acceptRcvFT_ db User {userId} fileId filePath userApprovedRelays rcvFileInline currentTs = do @@ -860,9 +860,9 @@ getLocalCryptoFile db userId fileId sent = pure $ CryptoFile filePath fileCryptoArgs _ -> throwError $ SEFileNotFound fileId -updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> VersionRangeChat -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem -updateDirectCIFileStatus db vr user fileId fileStatus = do - aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db vr user fileId +updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> StoreCxt -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem +updateDirectCIFileStatus db cxt user fileId fileStatus = do + aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db cxt user fileId case (cType, testEquality d $ msgDirection @d) of (SCTDirect, Just Refl) -> do liftIO $ updateCIFileStatus db user fileId fileStatus diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 8c207d99a7..4e38ef83e2 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -247,9 +247,9 @@ createGroupLink db gVar user@User {userId} groupInfo@GroupInfo {groupId, localDi void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff getGroupLink db user groupInfo -getGroupLinkConnection :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ExceptT StoreError IO Connection -getGroupLinkConnection db vr User {userId} groupInfo@GroupInfo {groupId} = - ExceptT . firstRow (toConnection vr) (SEGroupLinkNotFound groupInfo) $ +getGroupLinkConnection :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ExceptT StoreError IO Connection +getGroupLinkConnection db cxt User {userId} groupInfo@GroupInfo {groupId} = + ExceptT . firstRow (toConnection cxt) (SEGroupLinkNotFound groupInfo) $ DB.query db [sql| @@ -344,8 +344,8 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True} -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo -createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys publicMemberCount_ = ExceptT $ do +createNewGroup :: DB.Connection -> StoreCxt -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo +createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys publicMemberCount_ = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile (groupType_, groupLink_, publicGroupId_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) @@ -389,7 +389,7 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays ) insertedRowId db let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs vr + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo @@ -419,13 +419,13 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do +createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do - gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db vr user gId + gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db cxt user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership MemberIdRole {memberId = invMemberId, memberRole = invMemberRole} = invitedMember @@ -464,9 +464,9 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ |] ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db - let hostVRange = adjustedMemberVRange vr peerChatVRange + let hostVRange = adjustedMemberVRange (vr cxt) peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing Nothing currentTs hostVRange - membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs vr + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure ( GroupInfo @@ -608,8 +608,8 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) -createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ = do +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) +createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs @@ -623,11 +623,11 @@ createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile bu else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" let userMember = MemberIdRole userMemberId userMemberRole -- TODO [member keys] user key must be included here. Should key be added when group is prepared? - membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs vr - hostMember_ <- forM hostMemberId_ $ getGroupMember db vr user groupId + membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs (vr cxt) + hostMember_ <- forM hostMemberId_ $ getGroupMember db cxt user groupId forM_ hostMember_ $ \hostMember -> when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember - g <- getGroupInfo db vr user groupId + g <- getGroupInfo db cxt user groupId pure (g, hostMember_) where insertHost_ currentTs groupId groupLDN = do @@ -667,13 +667,13 @@ updateBusinessChatInfo db groupId businessChatInfo = |] (businessChatInfoRow businessChatInfo :. (Only groupId)) -updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMember -> User -> ExceptT StoreError IO GroupInfo -updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember_ newUser@User {userId = newUserId} = do +updatePreparedGroupUser :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupMember -> User -> ExceptT StoreError IO GroupInfo +updatePreparedGroupUser db cxt user gInfo@GroupInfo {groupId, membership} hostMember_ newUser@User {userId = newUserId} = do currentTs <- liftIO getCurrentTime updateGroup gInfo currentTs liftIO $ updateMembership membership currentTs forM_ hostMember_ $ \hostMember -> updateHostMember hostMember currentTs - getGroupInfo db vr newUser groupId + getGroupInfo db cxt newUser groupId where updateGroup GroupInfo {localDisplayName = oldGroupLDN, groupProfile = GroupProfile {displayName = groupDisplayName}} currentTs = ExceptT . withLocalDisplayName db newUserId groupDisplayName $ \newGroupLDN -> runExceptT $ do @@ -739,21 +739,21 @@ updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMem (newUserId, currentTs, hostProfileId) safeDeleteLDN db user oldHostLDN -updatePreparedUserAndHostMembersInvited :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -updatePreparedUserAndHostMembersInvited db vr user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do +updatePreparedUserAndHostMembersInvited :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembersInvited db cxt user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted - updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus + updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus -updatePreparedUserAndHostMembersRejected :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) -updatePreparedUserAndHostMembersRejected db vr user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do +updatePreparedUserAndHostMembersRejected :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembersRejected db cxt user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do let fromMemberProfile = profileFromName $ nameFromMemberId memberId - updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected -updatePreparedUserAndHostMembers' :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembers' :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) updatePreparedUserAndHostMembers' db - vr + cxt user gInfo@GroupInfo {groupId, membership, groupProfile = gp, businessChat} hostMember @@ -772,7 +772,7 @@ updatePreparedUserAndHostMembers' void $ updateGroupProfile db user gInfo groupProfile when (isJust businessChat && isJust business) $ liftIO $ updateBusinessChatInfo db groupId business - gInfo' <- getGroupInfo db vr user groupId + gInfo' <- getGroupInfo db cxt user groupId pure (gInfo', hostMember') where updateUserMember currentTs = do @@ -803,23 +803,23 @@ updatePreparedUserAndHostMembers' WHERE group_member_id = ? |] (memberId, memberRole, currentTs, gmId) - getGroupMemberById db vr user gmId + getGroupMemberById db cxt user gmId -createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do +createGroupInvitedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink db cxt user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted - createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus + createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus -createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupRejectedViaLink db vr user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do +createGroupRejectedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupRejectedViaLink db cxt user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do let fromMemberProfile = profileFromName $ nameFromMemberId memberId - createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected -createGroupViaLink' :: DB.Connection -> VersionRangeChat -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupViaLink' :: DB.Connection -> StoreCxt -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupViaLink' db - vr + cxt user@User {userId, userContactId} Connection {connId, customUserProfileId} fromMember @@ -834,9 +834,9 @@ createGroupViaLink' 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 -- TODO [member keys] this is currently not used with public groups. If it needs to be used, member keys need to be added - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs vr + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs (vr cxt) liftIO $ setViaGroupLinkUri db groupId connId - (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId + (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where insertHost_ currentTs groupId = do (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs @@ -897,10 +897,10 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO Group -getGroup db vr user groupId = do - gInfo <- getGroupInfo db vr user groupId - members <- liftIO $ getGroupMembers db vr user gInfo +getGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO Group +getGroup db cxt user groupId = do + gInfo <- getGroupInfo db cxt user groupId + members <- liftIO $ getGroupMembers db cxt user gInfo pure $ Group gInfo members deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () @@ -994,18 +994,18 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getInProgressGroups :: DB.Connection -> VersionRangeChat -> User -> UTCTime -> IO [GroupInfo] -getInProgressGroups db vr user@User {userId} createdAtCutoff = do +getInProgressGroups :: DB.Connection -> StoreCxt -> User -> UTCTime -> IO [GroupInfo] +getInProgressGroups db cxt user@User {userId} createdAtCutoff = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ?" (userId, createdAtCutoff) - rights <$> mapM (runExceptT . getGroupInfo db vr user) groupIds + rights <$> mapM (runExceptT . getGroupInfo db cxt user) groupIds -getBaseGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] -getBaseGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do - map (toGroupInfo vr userContactId []) +getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] +getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do + map (toGroupInfo cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1033,22 +1033,22 @@ getContactGroupPreferences db User {userId} Contact {contactId} = do |] (userId, contactId) -getGroupInfoByName :: DB.Connection -> VersionRangeChat -> User -> GroupName -> ExceptT StoreError IO GroupInfo -getGroupInfoByName db vr user gName = do +getGroupInfoByName :: DB.Connection -> StoreCxt -> User -> GroupName -> ExceptT StoreError IO GroupInfo +getGroupInfoByName db cxt user gName = do gId <- getGroupIdByName db user gName - getGroupInfo db vr user gId + getGroupInfo db cxt user gId -getGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db vr user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember :: DB.Connection -> StoreCxt -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMember db cxt user@User {userId} groupId groupMemberId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, groupMemberId, userId) -getHostMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db vr user groupId = - ExceptT . firstRow (toContactMember vr user) (SEGroupHostMemberNotFound groupId) $ +getHostMember :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupMember +getHostMember db cxt user groupId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") @@ -1087,46 +1087,46 @@ toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) let memberRef = Just CIMentionMember {groupMemberId, displayName, localAlias, memberRole} in CIMention {memberId, memberRef} -getGroupMemberById :: DB.Connection -> VersionRangeChat -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db vr user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById :: DB.Connection -> StoreCxt -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember +getGroupMemberById db cxt user@User {userId} groupMemberId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) -getGroupMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember -getGroupMemberByIndex db vr user GroupInfo {groupId} indexInGroup = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getGroupMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember +getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") (groupId, indexInGroup) -getSupportScopeMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember -getSupportScopeMemberByIndex db vr user GroupInfo {groupId} scopeGMId indexInGroup = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getSupportScopeMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember +getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) -getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember +getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = + ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") (groupId, memberId) -getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ContactName -> GroupMemberRole -> Bool -> ExceptT StoreError IO (Maybe (GroupMember, Bool)) -getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownMemberRole allowCreate = do - liftIO (runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case +getCreateUnknownGMByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ContactName -> GroupMemberRole -> Bool -> ExceptT StoreError IO (Maybe (GroupMember, Bool)) +getCreateUnknownGMByMemberId db cxt user gInfo memberId memberName unknownMemberRole allowCreate = do + liftIO (runExceptT $ getGroupMemberByMemberId db cxt user gInfo memberId) >>= \case Right m -> pure $ Just (m, False) Left (SEGroupMemberNotFoundByMemberId _) | allowCreate -> do let name = if T.null memberName then nameFromMemberId memberId else memberName - m <- createNewUnknownGroupMember db vr user gInfo memberId name unknownMemberRole + m <- createNewUnknownGroupMember db cxt user gInfo memberId name unknownMemberRole pure $ Just (m, True) | otherwise -> pure Nothing Left e -> throwError e @@ -1145,59 +1145,59 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" (userId, groupId, memberId) -getGroupMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = - map (toContactMember vr user) +getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userContactId) -getGroupMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> [Int64] -> IO [GroupMember] -getGroupMembersByIndexes db vr user gInfo indexesInGroup = do +getGroupMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> [Int64] -> IO [GroupMember] +getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do #if defined(dbPostgres) let GroupInfo {groupId} = gInfo - map (toContactMember vr user) <$> + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") (groupId, In indexesInGroup) #else - rights <$> mapM (runExceptT . getGroupMemberByIndex db vr user gInfo) indexesInGroup + rights <$> mapM (runExceptT . getGroupMemberByIndex db cxt user gInfo) indexesInGroup #endif -getSupportScopeMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] -getSupportScopeMembersByIndexes db vr user gInfo scopeGMId indexesInGroup = do +getSupportScopeMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] +getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do #if defined(dbPostgres) let GroupInfo {groupId} = gInfo - map (toContactMember vr user) <$> + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, In indexesInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) #else - rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db vr user gInfo scopeGMId) indexesInGroup + rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db cxt user gInfo scopeGMId) indexesInGroup #endif -getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr user) +getGroupModerators :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) -getGroupRelayMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupRelayMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr user) +getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") (userId, groupId, userContactId, GRRelay) -getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr user) +getGroupMembersForExpiration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupMembersForExpiration db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember cxt user) <$> DB.query db ( groupMemberQuery @@ -1212,14 +1212,14 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { ) (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) -getGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation -getGroupInvitation db vr user groupId = +getGroupInvitation :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation +getGroupInvitation db cxt user groupId = getConnRec_ user >>= \case Just connRequest -> do - groupInfo@GroupInfo {membership} <- getGroupInfo db vr user groupId + groupInfo@GroupInfo {membership} <- getGroupInfo db cxt user groupId when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined hostId <- getHostMemberId_ db user groupId - fromMember <- getGroupMember db vr user groupId hostId + fromMember <- getGroupMember db cxt user groupId hostId pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} _ -> throwError SEGroupInvitationNotFound where @@ -1357,8 +1357,8 @@ toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, f relayCap = RelayCapabilities {webDomain} in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap} -createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember -createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do +createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember +createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName displayName (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs @@ -1377,14 +1377,14 @@ createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {grou :. (userId, localDisplayName, memProfileId, currentTs, currentTs) ) liftIO $ insertedRowId db - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId -getCreateRelayForMember :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember -getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = +getCreateRelayForMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember +getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure where getGroupMemberByRelayLink = - maybeFirstRow (toContactMember vr user) $ + maybeFirstRow (toContactMember cxt user) $ DB.query db #if defined(dbPostgres) @@ -1415,10 +1415,10 @@ getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo { :. (userId, localDisplayName, profileId, currentTs, currentTs, relayLink) ) insertedRowId db - getGroupMember db vr user groupId groupMemberId + getGroupMember db cxt user groupId groupMemberId -createRelayConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection -createRelayConnection db vr user@User {userId} groupMemberId agentConnId connStatus chatV subMode = do +createRelayConnection :: DB.Connection -> StoreCxt -> User -> Int64 -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayConnection db cxt user@User {userId} groupMemberId agentConnId connStatus chatV subMode = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1435,7 +1435,7 @@ createRelayConnection db vr user@User {userId} groupMemberId agentConnId connSta :. (currentTs, currentTs) ) connId <- liftIO $ insertedRowId db - getConnectionById db vr user connId + getConnectionById db cxt user connId updateRelayStatus :: DB.Connection -> GroupRelay -> RelayStatus -> IO GroupRelay updateRelayStatus db relay@GroupRelay {groupRelayId} relayStatus = @@ -1452,8 +1452,8 @@ updateRelayStatus_ db relayId relayStatus = do currentTs <- getCurrentTime DB.execute db "UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ?" (relayStatus, currentTs, relayId) -setRelayLinkAccepted :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberKey -> Profile -> ExceptT StoreError IO (GroupMember, GroupRelay) -setRelayLinkAccepted db vr user m (MemberKey relayKey) profile = do +setRelayLinkAccepted :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberKey -> Profile -> ExceptT StoreError IO (GroupMember, GroupRelay) +setRelayLinkAccepted db cxt user m (MemberKey relayKey) profile = do let gmId = groupMemberId' m currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1473,7 +1473,7 @@ setRelayLinkAccepted db vr user m (MemberKey relayKey) profile = do |] (relayKey, currentTs, gmId) void $ updateMemberProfile db user m profile - (,) <$> getGroupMemberById db vr user gmId <*> getGroupRelayByGMId db gmId + (,) <$> getGroupMemberById db cxt user gmId <*> getGroupRelayByGMId db gmId setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () setRelayLinkConfId db m confId relayLink = do @@ -1541,8 +1541,8 @@ setGroupInProgressDone db GroupInfo {groupId} = do "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" (currentTs, groupId) -createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) -createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay memberStatus relayStatus = do +createRelayRequestGroup :: DB.Connection -> StoreCxt -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay memberStatus relayStatus = do currentTs <- liftIO getCurrentTime -- Create group with placeholder profile let Profile {displayName = fromMemberLDN} = fromMemberProfile @@ -1562,9 +1562,9 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe ownerMemberId <- insertOwner_ currentTs groupId let relayMember = MemberIdRole relayMemberId GRRelay -- TODO [member keys] should relays use member keys? - _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs vr - ownerMember <- getGroupMember db vr user groupId ownerMemberId - g <- getGroupInfo db vr user groupId + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs (vr cxt) + ownerMember <- getGroupMember db cxt user groupId ownerMemberId + g <- getGroupInfo db cxt user groupId pure (g, ownerMember) where setRelayRequestData_ groupId currentTs = @@ -1616,8 +1616,8 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do -- Flip every RSRejected row sharing the targeted group's relay_request_group_link -- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId. -allowRelayGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupInfo -allowRelayGroup db vr user@User {userId} groupId = do +allowRelayGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupInfo +allowRelayGroup db cxt user@User {userId} groupId = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1630,7 +1630,7 @@ allowRelayGroup db vr user@User {userId} groupId = do AND relay_own_status = ? |] (RSInactive, currentTs, currentTs, userId, groupId, RSRejected) - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId isRelayGroupRejected :: DB.Connection -> User -> ShortLinkContact -> IO Bool isRelayGroupRejected db User {userId} groupLink = @@ -1649,9 +1649,9 @@ isRelayGroupRejected db User {userId} groupLink = (userId, groupLink, RSRejected) ) -getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo] -getRelayServedGroups db vr User {userId, userContactId} = do - map (toGroupInfo vr userContactId []) +getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] +getRelayServedGroups db cxt User {userId, userContactId} = do + map (toGroupInfo cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1659,10 +1659,10 @@ getRelayServedGroups db vr User {userId, userContactId} = do ) (userId, userContactId, RSAccepted, RSActive) -getRelayInactiveGroups :: DB.Connection -> VersionRangeChat -> User -> NominalDiffTime -> IO [GroupInfo] -getRelayInactiveGroups db vr User {userId, userContactId} ttl = do +getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] +getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime - map (toGroupInfo vr userContactId []) + map (toGroupInfo cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1774,10 +1774,10 @@ createJoiningMemberConnection Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV cReqChatVRange Nothing (Just uclId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId -createBusinessRequestGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfo, GroupMember) +createBusinessRequestGroup :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> VersionRangeChat -> Profile -> Int64 -> Text -> GroupPreferences -> ExceptT StoreError IO (GroupInfo, GroupMember) createBusinessRequestGroup db - vr + cxt gVar user@User {userId, userContactId} cReqChatVRange @@ -1789,8 +1789,8 @@ createBusinessRequestGroup (groupId, membership@GroupMember {memberId = userMemberId}) <- insertGroup_ currentTs (groupMemberId, memberId) <- insertClientMember_ currentTs groupId membership liftIO $ DB.execute db "UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ?" (userMemberId, memberId, groupId) - groupInfo <- getGroupInfo db vr user groupId - clientMember <- getGroupMemberById db vr user groupMemberId + groupInfo <- getGroupInfo db cxt user groupId + clientMember <- getGroupMemberById db cxt user groupMemberId pure (groupInfo, clientMember) where insertGroup_ currentTs = do @@ -1813,7 +1813,7 @@ createBusinessRequestGroup groupId <- liftIO $ insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 -- TODO [member keys] we could support member keys in business groups to allow binding agreements (though identity keys would be better for it. - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs vr + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs (vr cxt) pure (groupId, membership) VersionRange minV maxV = cReqChatVRange insertClientMember_ currentTs groupId membership = @@ -1837,8 +1837,8 @@ createBusinessRequestGroup groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) -getContactViaMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> ExceptT StoreError IO Contact -getContactViaMember db vr user@User {userId} GroupMember {groupMemberId} = do +getContactViaMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> ExceptT StoreError IO Contact +getContactViaMember db cxt user@User {userId} GroupMember {groupMemberId} = do contactId <- ExceptT $ firstRow fromOnly (SEContactNotFoundByMemberId groupMemberId) $ @@ -1852,7 +1852,7 @@ getContactViaMember db vr user@User {userId} GroupMember {groupMemberId} = do LIMIT 1 |] (userId, groupMemberId) - getContact db vr user contactId + getContact db cxt user contactId setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -1879,18 +1879,18 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon -- This is called once before connecting to relays, unlike createConnReqConnection -> setPreparedGroupLinkInfo_, -- which is used in single-connection flows. updatePreparedRelayedGroup :: - DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> + DB.Connection -> StoreCxt -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> Maybe Int64 -> ExceptT StoreError IO GroupInfo -updatePreparedRelayedGroup db vr user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey memberPrivKey publicMemberCount_ = do +updatePreparedRelayedGroup db cxt user@User {userId} gInfo cReq cReqHash incognitoProfile rootPubKey memberPrivKey publicMemberCount_ = do currentTs <- liftIO getCurrentTime customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId publicMemberCount_ currentTs liftIO $ updateGroupMemberKeys db (groupId' gInfo) rootPubKey memberPrivKey (groupMemberId' $ membership gInfo) - getGroupInfo db vr user (groupId' gInfo) + getGroupInfo db cxt user (groupId' gInfo) -updatePublicMemberCount :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ExceptT StoreError IO GroupInfo -updatePublicMemberCount db vr user GroupInfo {groupId} = do +updatePublicMemberCount :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ExceptT StoreError IO GroupInfo +updatePublicMemberCount db cxt user GroupInfo {groupId} = do liftIO $ do totalCount <- fromMaybe 0 <$> maybeFirstRow fromOnly (DB.query db "SELECT summary_current_members_count FROM groups WHERE group_id = ?" (Only groupId)) @@ -1906,13 +1906,13 @@ updatePublicMemberCount db vr user GroupInfo {groupId} = do let publicCount = max 0 (totalCount - relayCount) :: Int64 currentTs <- getCurrentTime DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId -setPublicMemberCount :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupInfo -setPublicMemberCount db vr user GroupInfo {groupId} publicCount = do +setPublicMemberCount :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupInfo +setPublicMemberCount db cxt user GroupInfo {groupId} publicCount = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) - getGroupInfo db vr user groupId + getGroupInfo db cxt user groupId updateGroupMemberKeys :: DB.Connection -> GroupId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO () updateGroupMemberKeys db groupId rootPubKey memberPrivKey membershipGMId = do @@ -2402,8 +2402,8 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName let publicGroupAccess = toPublicGroupAccess accessRow in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} -getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) -getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> StoreCxt -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do -- fmap join is to support group_id = NULL if non-group contact request is sent to this function (e.g., if client data is appended). groupId_ <- fmap join . maybeFirstRow fromOnly $ @@ -2415,12 +2415,12 @@ getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReq WHERE user_id = ? AND conn_req_contact IN (?,?) |] (userId, cReqSchema1, cReqSchema2) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db cxt user) groupId_ -getGroupInfoViaUserShortLink :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> IO (Maybe (ConnReqContact, GroupInfo)) -getGroupInfoViaUserShortLink db vr user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do +getGroupInfoViaUserShortLink :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> IO (Maybe (ConnReqContact, GroupInfo)) +getGroupInfoViaUserShortLink db cxt user@User {userId} shortLink = fmap eitherToMaybe $ runExceptT $ do (cReq, groupId) <- ExceptT getConnReqGroup - (cReq,) <$> getGroupInfo db vr user groupId + (cReq,) <$> getGroupInfo db cxt user groupId where getConnReqGroup = firstRow' toConnReqGroupId (SEInternalError "group link not found") $ @@ -2437,14 +2437,14 @@ getGroupInfoViaUserShortLink db vr user@User {userId} shortLink = fmap eitherToM (cReq, Just groupId) -> Right (cReq, groupId) _ -> Left $ SEInternalError "no conn req or group ID" -getGroupViaShortLinkToConnect :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> ExceptT StoreError IO (Maybe (ConnReqContact, GroupInfo)) -getGroupViaShortLinkToConnect db vr user@User {userId} shortLink = +getGroupViaShortLinkToConnect :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> ExceptT StoreError IO (Maybe (ConnReqContact, GroupInfo)) +getGroupViaShortLinkToConnect db cxt user@User {userId} shortLink = liftIO (maybeFirstRow id $ DB.query db "SELECT group_id, conn_full_link_to_connect FROM groups WHERE user_id = ? AND conn_short_link_to_connect = ?" (userId, shortLink)) >>= \case - Just (gId :: Int64, Just cReq) -> Just . (cReq,) <$> getGroupInfo db vr user gId + Just (gId :: Int64, Just cReq) -> Just . (cReq,) <$> getGroupInfo db cxt user gId _ -> pure Nothing -getGroupInfoByGroupLinkHash :: DB.Connection -> VersionRangeChat -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) -getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do +getGroupInfoByGroupLinkHash :: DB.Connection -> StoreCxt -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash db cxt user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do groupId_ <- maybeFirstRow fromOnly $ DB.query @@ -2458,7 +2458,7 @@ getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHa LIMIT 1 |] (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db cxt user) groupId_ getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId getGroupIdByName db User {userId} gName = @@ -2470,8 +2470,8 @@ getGroupMemberIdByName db User {userId} groupId groupMemberName = ExceptT . firstRow fromOnly (SEGroupMemberNameNotFound groupId groupMemberName) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ?" (userId, groupId, groupMemberName) -getActiveMembersByName :: DB.Connection -> VersionRangeChat -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] -getActiveMembersByName db vr user@User {userId} groupMemberName = do +getActiveMembersByName :: DB.Connection -> StoreCxt -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)] +getActiveMembersByName db cxt user@User {userId} groupMemberName = do groupMemberIds :: [(GroupId, GroupMemberId)] <- liftIO $ DB.query @@ -2484,17 +2484,17 @@ getActiveMembersByName db vr user@User {userId} groupMemberName = do |] (userId, groupMemberName, GSMemConnected, GSMemComplete, GCUserMember) possibleMembers <- forM groupMemberIds $ \(groupId, groupMemberId) -> do - groupInfo <- getGroupInfo db vr user groupId - groupMember <- getGroupMember db vr user groupId groupMemberId + groupInfo <- getGroupInfo db cxt user groupId + groupMember <- getGroupMember db cxt user groupId groupMemberId pure (groupInfo, groupMember) pure $ sortOn (Down . ts . fst) possibleMembers where ts GroupInfo {chatTs, updatedAt} = fromMaybe updatedAt chatTs -getMatchingContacts :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO [Contact] -getMatchingContacts db vr user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, shortDescr, image}} = do +getMatchingContacts :: DB.Connection -> StoreCxt -> User -> Contact -> IO [Contact] +getMatchingContacts db cxt user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, shortDescr, image}} = do contactIds <- map fromOnly <$> DB.query db q (userId, contactId, CSActive, displayName, fullName, shortDescr, image) - rights <$> mapM (runExceptT . getContact db vr user) contactIds + rights <$> mapM (runExceptT . getContact db cxt user) contactIds where -- this query is different from one in getMatchingMemberContacts -- it checks that it's not the same contact @@ -2509,10 +2509,10 @@ getMatchingContacts db vr user@User {userId} Contact {contactId, profile = Local AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? |] -getMatchingMembers :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO [GroupMember] -getMatchingMembers db vr user@User {userId} Contact {profile = LocalProfile {displayName, fullName, shortDescr, image}} = do +getMatchingMembers :: DB.Connection -> StoreCxt -> User -> Contact -> IO [GroupMember] +getMatchingMembers db cxt user@User {userId} Contact {profile = LocalProfile {displayName, fullName, shortDescr, image}} = do memberIds <- map fromOnly <$> DB.query db q (userId, GCUserMember, displayName, fullName, shortDescr, image) - filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db cxt user) memberIds where -- only match with members without associated contact q = @@ -2526,11 +2526,11 @@ getMatchingMembers db vr user@User {userId} Contact {profile = LocalProfile {dis AND p.short_descr IS NOT DISTINCT FROM ? AND p.image IS NOT DISTINCT FROM ? |] -getMatchingMemberContacts :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [Contact] +getMatchingMemberContacts :: DB.Connection -> StoreCxt -> User -> GroupMember -> IO [Contact] getMatchingMemberContacts _ _ _ GroupMember {memberContactId = Just _} = pure [] -getMatchingMemberContacts db vr user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} = do +getMatchingMemberContacts db cxt user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} = do contactIds <- map fromOnly <$> DB.query db q (userId, CSActive, displayName, fullName, shortDescr, image) - rights <$> mapM (runExceptT . getContact db vr user) contactIds + rights <$> mapM (runExceptT . getContact db cxt user) contactIds where q = [sql| @@ -2563,8 +2563,8 @@ createSentProbeHash db userId probeId to = do "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 -> VersionRangeChat -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] -matchReceivedProbe db vr user@User {userId} from (Probe probe) = do +matchReceivedProbe :: DB.Connection -> StoreCxt -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] +matchReceivedProbe db cxt user@User {userId} from (Probe probe) = do let probeHash = C.sha256Hash probe cgmIds <- DB.query @@ -2585,7 +2585,7 @@ matchReceivedProbe db vr user@User {userId} from (Probe probe) = do "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, Binary probe, Binary probeHash, userId, currentTs, currentTs) let cgmIds' = filterFirstContactId cgmIds - catMaybes <$> mapM (getContactOrMember_ db vr user) cgmIds' + catMaybes <$> mapM (getContactOrMember_ db cxt user) cgmIds' where filterFirstContactId :: [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] -> [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] filterFirstContactId cgmIds = do @@ -2595,8 +2595,8 @@ matchReceivedProbe db vr user@User {userId} from (Probe probe) = do (x : _) -> [x] ctIds' <> memIds -matchReceivedProbeHash :: DB.Connection -> VersionRangeChat -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) -matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do +matchReceivedProbeHash :: DB.Connection -> StoreCxt -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) +matchReceivedProbeHash db cxt user@User {userId} from (ProbeHash probeHash) = do probeIds <- maybeFirstRow id $ DB.query @@ -2616,11 +2616,11 @@ matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, Binary probeHash, userId, currentTs, currentTs) - pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db vr user cgmIds + pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db cxt user cgmIds -matchSentProbe :: DB.Connection -> VersionRangeChat -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) -matchSentProbe db vr user@User {userId} _from (Probe probe) = do - cgmIds $>>= getContactOrMember_ db vr user +matchSentProbe :: DB.Connection -> StoreCxt -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) +matchSentProbe db cxt user@User {userId} _from (Probe probe) = do + cgmIds $>>= getContactOrMember_ db cxt user where (ctId, gmId) = contactOrMemberIds _from cgmIds = @@ -2639,11 +2639,11 @@ matchSentProbe db vr user@User {userId} _from (Probe probe) = do |] (userId, Binary probe, ctId, gmId) -getContactOrMember_ :: DB.Connection -> VersionRangeChat -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) -getContactOrMember_ db vr user ids = +getContactOrMember_ :: DB.Connection -> StoreCxt -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) +getContactOrMember_ db cxt user ids = fmap eitherToMaybe . runExceptT $ case ids of - (Just ctId, _, _) -> COMContact <$> getContact db vr user ctId - (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db vr user gId gmId + (Just ctId, _, _) -> COMContact <$> getContact db cxt user ctId + (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db cxt user gId gmId _ -> throwError $ SEInternalError "" associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () @@ -2664,10 +2664,10 @@ associateMemberWithContactRecord when (memProfileId /= profileId) $ deleteUnusedProfile_ db userId memProfileId when (memLDN /= localDisplayName) $ deleteUnusedDisplayName_ db userId memLDN -associateContactWithMemberRecord :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact +associateContactWithMemberRecord :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact associateContactWithMemberRecord db - vr + cxt user@User {userId} GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} = do @@ -2691,7 +2691,7 @@ associateContactWithMemberRecord (memLDN, memProfileId, currentTs, userId, contactId) when (profileId /= memProfileId) $ deleteUnusedProfile_ db userId profileId when (localDisplayName /= memLDN) $ deleteUnusedDisplayName_ db userId localDisplayName - getContact db vr user contactId + getContact db cxt user contactId deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = @@ -2847,15 +2847,15 @@ createMemberContact mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} -getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) -getMemberContact db vr user contactId = do - ct <- getContact db vr user contactId +getMemberContact :: DB.Connection -> StoreCxt -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) +getMemberContact db cxt user contactId = do + ct <- getContact db cxt user contactId let Contact {contactGroupMemberId, activeConn} = ct case (activeConn, contactGroupMemberId) of (Just Connection {connId}, Just groupMemberId) -> do cReq <- getConnReqInv db connId - m@GroupMember {groupId} <- getGroupMemberById db vr user groupMemberId - g <- getGroupInfo db vr user groupId + m@GroupMember {groupId} <- getGroupMemberById db cxt user groupMemberId + g <- getGroupInfo db cxt user groupId pure (g, m, ct, cReq) _ -> throwError $ SEMemberContactGroupMemberNotFound contactId @@ -2964,13 +2964,13 @@ createMemberContactConn forM_ cmdId_ $ \cmdId -> setCommandConnId db user cmdId connId pure connId -getMemberContactInvited :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, Connection, Contact, GroupDirectInvitation) -getMemberContactInvited db vr user contactId = do - ct@Contact {groupDirectInv = groupDirectInv_} <- getContact db vr user contactId +getMemberContactInvited :: DB.Connection -> StoreCxt -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, Connection, Contact, GroupDirectInvitation) +getMemberContactInvited db cxt user contactId = do + ct@Contact {groupDirectInv = groupDirectInv_} <- getContact db cxt user contactId case groupDirectInv_ of Just groupDirectInv@GroupDirectInvitation {fromGroupId_ = Just groupId, fromGroupMemberId_ = Just _gmId, fromGroupMemberConnId_ = Just mConnId} -> do - g <- getGroupInfo db vr user groupId - mConn <- getConnectionById db vr user mConnId + g <- getGroupInfo db cxt user groupId + mConn <- getConnectionById db cxt user mConnId pure (g, mConn, ct, groupDirectInv) _ -> throwError $ SEMemberContactGroupMemberNotFound contactId @@ -3032,8 +3032,8 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" (BI xGrpLinkMemReceived, currentTs, mId) -createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> GroupMemberRole -> ExceptT StoreError IO GroupMember -createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do +createNewUnknownGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> Text -> GroupMemberRole -> ExceptT StoreError IO GroupMember +createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs @@ -3053,12 +3053,12 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g :. (minV, maxV) ) groupMemberId <- liftIO $ insertedRowId db - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where - VersionRange minV maxV = vr + VersionRange minV maxV = vr cxt -createLinkOwnerMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe ContactId -> MemberId -> C.PublicKeyEd25519 -> ExceptT StoreError IO GroupMember -createLinkOwnerMember db vr user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do +createLinkOwnerMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe ContactId -> MemberId -> C.PublicKeyEd25519 -> ExceptT StoreError IO GroupMember +createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName $ nameFromMemberId memberId (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs @@ -3078,15 +3078,15 @@ createLinkOwnerMember db vr user@User {userId, userContactId} GroupInfo {groupId :. (minV, maxV) ) groupMemberId <- liftIO $ insertedRowId db - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where - VersionRange minV maxV = vr + VersionRange minV maxV = vr cxt -- member_pub_key is not updated here — introduced members are owners -- whose keys are loaded from link data (trusted out-of-band). -- Updating from an in-band message would allow a compromised relay to substitute keys. -updatePreparedChannelMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updatePreparedChannelMember db vr user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do +updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember +updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do _ <- updateMemberProfile db user member profile currentTs <- liftIO getCurrentTime liftIO $ @@ -3102,12 +3102,12 @@ updatePreparedChannelMember db vr user@User {userId} member@GroupMember {groupMe WHERE user_id = ? AND group_member_id = ? |] (memberRole, GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId) - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v -updateUnknownMemberAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do +updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do _ <- updateMemberProfile db user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ @@ -3128,7 +3128,7 @@ updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMemb ( (memberRole, GCPostMember, status, groupMemberId' invitingMember) :. (minV, maxV, memberPubKey_, currentTs, userId, groupMemberId) ) - getGroupMemberById db vr user groupMemberId + getGroupMemberById db cxt user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 5d433088a4..76e0a0fd97 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -396,8 +396,8 @@ data MemberAttention | MAReset deriving (Show) -updateChatTsStats :: DB.Connection -> VersionRangeChat -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO (ChatInfo c) -updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of +updateChatTsStats :: DB.Connection -> StoreCxt -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO (ChatInfo c) +updateChatTsStats db cxt user@User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of DirectChat ct@Contact {contactId} -> do DB.execute db @@ -506,7 +506,7 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas WHERE group_member_id = ? |] (chatTs, unread, mentions, groupMemberId) - m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + m_ <- runExceptT $ getGroupMemberById db cxt user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it LocalChat nf@NoteFolder {noteFolderId} -> do DB.execute @@ -520,8 +520,8 @@ setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () setSupportChatTs db groupMemberId chatTs = DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) -setSupportChatMemberAttention :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) -setSupportChatMemberAttention db vr user g m memberAttention = do +setSupportChatMemberAttention :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) +setSupportChatMemberAttention db cxt user g m memberAttention = do m' <- updateGMAttention g' <- updateGroupMembersRequireAttention db user g m m' pure (g', m') @@ -532,7 +532,7 @@ setSupportChatMemberAttention db vr user g m memberAttention = do db "UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?" (memberAttention, currentTs, groupMemberId' m) - m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m) + m_ <- runExceptT $ getGroupMemberById db cxt user (groupMemberId' m) pure $ either (const m) id m_ -- Left shouldn't happen, but types require it createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId @@ -723,8 +723,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow -getChatPreviews :: DB.Connection -> VersionRangeChat -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] -getChatPreviews db vr user withPCC pagination query = do +getChatPreviews :: DB.Connection -> StoreCxt -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] +getChatPreviews db cxt user withPCC pagination query = do directChats <- findDirectChatPreviews_ db user pagination query groupChats <- findGroupChatPreviews_ db user pagination query localChats <- findLocalChatPreviews_ db user pagination query @@ -746,8 +746,8 @@ getChatPreviews db vr user withPCC pagination query = do PTBefore _ count -> take count . sortBy (comparing $ Down . ts) getChatPreview :: AChatPreviewData -> ExceptT StoreError IO AChat getChatPreview (ACPD cType cpd) = case cType of - SCTDirect -> getDirectChatPreview_ db vr user cpd - SCTGroup -> getGroupChatPreview_ db vr user cpd + SCTDirect -> getDirectChatPreview_ db cxt user cpd + SCTGroup -> getGroupChatPreview_ db cxt user cpd SCTLocal -> getLocalChatPreview_ db user cpd SCTContactRequest -> let (ContactRequestPD _ chat) = cpd in pure chat SCTContactConnection -> let (ContactConnectionPD _ chat) = cpd in pure chat @@ -864,9 +864,9 @@ findDirectChatPreviews_ db User {userId} pagination clq = PTAfter ts count -> DB.query db (query <> " AND ct.chat_ts > ? ORDER BY ct.chat_ts ASC LIMIT ?") (params :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ?") (params :. (ts, count)) -getDirectChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat -getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do - contact <- getContact db vr user contactId +getDirectChatPreview_ :: DB.Connection -> StoreCxt -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat +getDirectChatPreview_ db cxt user (DirectChatPD _ contactId lastItemId_ stats) = do + contact <- getContact db cxt user contactId ts <- liftIO getCurrentTime lastItem <- case lastItemId_ of Just lastItemId -> do @@ -975,9 +975,9 @@ findGroupChatPreviews_ db User {userId} pagination clq = PTAfter ts count -> DB.query db (query <> " AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ?") (params :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ?") (params :. (ts, count)) -getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat -getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do - groupInfo <- getGroupInfo db vr user groupId +getGroupChatPreview_ :: DB.Connection -> StoreCxt -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat +getGroupChatPreview_ db cxt user (GroupChatPD _ groupId lastItemId_ stats) = do + groupInfo <- getGroupInfo db cxt user groupId ts <- liftIO getCurrentTime lastItem <- case lastItemId_ of Just lastItemId -> do @@ -1213,10 +1213,10 @@ getChatContentTypes db User {userId} (ChatRef cType chatId chatScope_) = case cT ("SELECT DISTINCT msg_content_tag FROM chat_items WHERE user_id = ? AND " <> cond <> " AND msg_content_tag IS NOT NULL ORDER BY msg_content_tag") ((userId, chatId) :. params) -getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) -getDirectChat db vr user contactId contentFilter pagination search_ = do +getDirectChat :: DB.Connection -> StoreCxt -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChat db cxt user contactId contentFilter pagination search_ = do let search = fromMaybe "" search_ - ct <- getContact db vr user contactId + ct <- getContact db cxt user contactId case pagination of CPLast count -> (,Nothing) <$> getDirectChatLast_ db user ct contentFilter count search CPAfter afterId count -> (,Nothing) <$> getDirectChatAfter_ db user ct contentFilter afterId count search @@ -1433,11 +1433,11 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do +getGroupChat :: DB.Connection -> StoreCxt -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat db cxt user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ - g <- getGroupInfo db vr user groupId - scopeInfo <- mapM (getCreateGroupChatScopeInfo db vr user g) scope_ + g <- getGroupInfo db cxt user groupId + scopeInfo <- mapM (getCreateGroupChatScopeInfo db cxt user g) scope_ case pagination of CPLast count -> (,Nothing) <$> getGroupChatLast_ db user g scopeInfo contentFilter count search emptyChatStats CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g scopeInfo contentFilter afterId count search @@ -1447,31 +1447,31 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getGroupChatInitial_ db user g scopeInfo contentFilter count -getCreateGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo -getCreateGroupChatScopeInfo db vr user GroupInfo {membership} = \case +getCreateGroupChatScopeInfo :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo +getCreateGroupChatScopeInfo db cxt user GroupInfo {membership} = \case GCSMemberSupport Nothing -> do when (isNothing $ supportChat membership) $ do ts <- liftIO getCurrentTime liftIO $ setSupportChatTs db (groupMemberId' membership) ts pure $ GCSIMemberSupport {groupMember_ = Nothing} GCSMemberSupport (Just gmId) -> do - m <- getGroupMemberById db vr user gmId + m <- getGroupMemberById db cxt user gmId when (isNothing $ supportChat m) $ do ts <- liftIO getCurrentTime liftIO $ setSupportChatTs db gmId ts pure GCSIMemberSupport {groupMember_ = Just m} -getGroupChatScopeInfoForItem :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScopeInfo) -getGroupChatScopeInfoForItem db vr user g itemId = - getGroupChatScopeForItem_ db itemId >>= mapM (getGroupChatScopeInfo db vr user g) +getGroupChatScopeInfoForItem :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScopeInfo) +getGroupChatScopeInfoForItem db cxt user g itemId = + getGroupChatScopeForItem_ db itemId >>= mapM (getGroupChatScopeInfo db cxt user g) -getGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo -getGroupChatScopeInfo db vr user GroupInfo {membership} = \case +getGroupChatScopeInfo :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo +getGroupChatScopeInfo db cxt user GroupInfo {membership} = \case GCSMemberSupport Nothing -> case supportChat membership of Nothing -> throwError $ SEInternalError "no moderators support chat" Just _supportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} GCSMemberSupport (Just gmId) -> do - m <- getGroupMemberById db vr user gmId + m <- getGroupMemberById db cxt user gmId case supportChat m of Nothing -> throwError $ SEInternalError "no support chat" Just _supportChat -> pure GCSIMemberSupport {groupMember_ = Just m} @@ -2077,8 +2077,8 @@ updateGroupChatItemsRead db User {userId} GroupInfo {groupId} = do |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) -updateSupportChatItemsRead :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember) -updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do +updateSupportChatItemsRead :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember) +updateSupportChatItemsRead db cxt user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do currentTs <- getCurrentTime case scopeInfo of GCSIMemberSupport {groupMember_} -> do @@ -2116,7 +2116,7 @@ updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, member WHERE group_member_id = ? |] (currentTs, groupMemberId) - m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + m_ <- runExceptT $ getGroupMemberById db cxt user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> Maybe GroupChatScope -> IO [(ChatItemId, Int)] @@ -2144,8 +2144,8 @@ getGroupUnreadTimedItems db User {userId} groupId scope = |] (userId, groupId, GCSTMemberSupport_, groupMemberId_, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) -updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do +updateGroupChatItemsReadList :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) +updateGroupChatItemsReadList db cxt user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do currentTs <- liftIO getCurrentTime -- Possible improvement is to differentiate retrieval queries for each scope, -- but we rely on UI to not pass item IDs from incorrect scope. @@ -2154,7 +2154,7 @@ updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scop Nothing -> pure g Just scopeInfo@GCSIMemberSupport {groupMember_} -> do let decStats = countReadItems groupMember_ readItemsData - liftIO $ updateGroupScopeUnreadStats db vr user g scopeInfo decStats + liftIO $ updateGroupScopeUnreadStats db cxt user g scopeInfo decStats pure (timedItems readItemsData, g') where getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) @@ -2189,8 +2189,8 @@ updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scop addTimedItem acc (itemId, Just ttl, Nothing, _, _) = (itemId, ttl) : acc addTimedItem acc _ = acc -updateGroupScopeUnreadStats :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO GroupInfo -updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unread, unanswered, mentions) = +updateGroupScopeUnreadStats :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO GroupInfo +updateGroupScopeUnreadStats db cxt user g@GroupInfo {membership} scopeInfo (unread, unanswered, mentions) = case scopeInfo of GCSIMemberSupport {groupMember_} -> case groupMember_ of Nothing -> do @@ -2228,7 +2228,7 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea |] #endif (unread, unanswered, mentions, currentTs, groupMemberId) - m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + m_ <- runExceptT $ getGroupMemberById db cxt user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] @@ -2403,8 +2403,8 @@ toGroupChatItem ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe Text -> ExceptT StoreError IO [AChatItem] -getAllChatItems db vr user@User {userId} pagination search_ = do +getAllChatItems :: DB.Connection -> StoreCxt -> User -> ChatPagination -> Maybe Text -> ExceptT StoreError IO [AChatItem] +getAllChatItems db cxt user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of CPLast count -> liftIO $ getAllChatItemsLast_ count @@ -2416,12 +2416,12 @@ getAllChatItems db vr user@User {userId} pagination search_ = do liftIO getFirstUnreadItemId_ >>= \case Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId Nothing -> liftIO $ getAllChatItemsLast_ count - mapM (uncurry (getAChatItem db vr user)) itemRefs + mapM (uncurry (getAChatItem db cxt user)) itemRefs where search = fromMaybe "" search_ getAChatItem_ itemId = do chatRef <- getChatRefViaItemId db user itemId - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId getAllChatItemsLast_ count = reverse <$> DB.query @@ -3208,8 +3208,8 @@ deleteLocalChatItem db User {userId} NoteFolder {noteFolderId} ci = do |] (userId, noteFolderId, itemId) -getChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO AChatItem -getChatItemByFileId db vr user@User {userId} fileId = do +getChatItemByFileId :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO AChatItem +getChatItemByFileId db cxt user@User {userId} fileId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByFileId fileId) $ DB.query @@ -3222,16 +3222,16 @@ getChatItemByFileId db vr user@User {userId} fileId = do LIMIT 1 |] (userId, fileId) - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId -lookupChatItemByFileId :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) -lookupChatItemByFileId db vr user fileId = do - fmap Just (getChatItemByFileId db vr user fileId) `catchError` \case +lookupChatItemByFileId :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO (Maybe AChatItem) +lookupChatItemByFileId db cxt user fileId = do + fmap Just (getChatItemByFileId db cxt user fileId) `catchError` \case SEChatItemNotFoundByFileId {} -> pure Nothing e -> throwError e -getChatItemByGroupId :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO AChatItem -getChatItemByGroupId db vr user@User {userId} groupId = do +getChatItemByGroupId :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO AChatItem +getChatItemByGroupId db cxt user@User {userId} groupId = do (chatRef, itemId) <- ExceptT . firstRow' toChatItemRef (SEChatItemNotFoundByGroupId groupId) $ DB.query @@ -3244,7 +3244,7 @@ getChatItemByGroupId db vr user@User {userId} groupId = do LIMIT 1 |] (userId, groupId) - getAChatItem db vr user chatRef itemId + getAChatItem db cxt user chatRef itemId getChatRefViaItemId :: DB.Connection -> User -> ChatItemId -> ExceptT StoreError IO ChatRef getChatRefViaItemId db User {userId} itemId = do @@ -3257,17 +3257,17 @@ getChatRefViaItemId db User {userId} itemId = do (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId Nothing (_, _) -> Left $ SEBadChatItem itemId Nothing -getAChatItem :: DB.Connection -> VersionRangeChat -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem -getAChatItem db vr user (ChatRef cType chatId scope) itemId = do +getAChatItem :: DB.Connection -> StoreCxt -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem +getAChatItem db cxt user (ChatRef cType chatId scope) itemId = do aci <- case cType of CTDirect -> do - ct <- getContact db vr user chatId + ct <- getContact db cxt user chatId (CChatItem msgDir ci) <- getDirectChatItem db user chatId itemId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci CTGroup -> do - gInfo <- getGroupInfo db vr user chatId + gInfo <- getGroupInfo db cxt user chatId (CChatItem msgDir ci) <- getGroupChatItem db user chatId itemId - scopeInfo <- mapM (getGroupChatScopeInfo db vr user gInfo) scope + scopeInfo <- mapM (getGroupChatScopeInfo db cxt user gInfo) scope pure $ AChatItem SCTGroup msgDir (GroupChat gInfo scopeInfo) ci CTLocal -> do nf <- getNoteFolder db user chatId @@ -3443,8 +3443,8 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti |] (groupId, groupMemberId' m, itemSharedMId, itemMemberId, BI sent, reaction) -getReactionMembers :: DB.Connection -> VersionRangeChat -> User -> GroupId -> SharedMsgId -> MsgReaction -> IO [MemberReaction] -getReactionMembers db vr user groupId itemSharedMId reaction = do +getReactionMembers :: DB.Connection -> StoreCxt -> User -> GroupId -> SharedMsgId -> MsgReaction -> IO [MemberReaction] +getReactionMembers db cxt user groupId itemSharedMId reaction = do reactions <- DB.query db @@ -3458,7 +3458,7 @@ getReactionMembers db vr user groupId itemSharedMId reaction = do where toMemberReaction :: (GroupMemberId, UTCTime) -> ExceptT StoreError IO MemberReaction toMemberReaction (groupMemberId, reactionTs) = do - groupMember <- getGroupMemberById db vr user groupMemberId + groupMember <- getGroupMemberById db cxt user groupMemberId pure MemberReaction {groupMember, reactionTs} getTimedItems :: DB.Connection -> User -> UTCTime -> IO [((ChatRef, ChatItemId), UTCTime)] @@ -3556,9 +3556,9 @@ createCIModeration db GroupInfo {groupId} moderatorMember itemMemberId itemShare |] (groupId, groupMemberId' moderatorMember, itemMemberId, itemSharedMId, msgId, moderatedAtTs) -getCIModeration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) +getCIModeration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> Maybe SharedMsgId -> IO (Maybe CIModeration) getCIModeration _ _ _ _ _ Nothing = pure Nothing -getCIModeration db vr user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do +getCIModeration db cxt user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = do r_ <- maybeFirstRow id $ DB.query @@ -3572,7 +3572,7 @@ getCIModeration db vr user GroupInfo {groupId} itemMemberId (Just sharedMsgId) = (groupId, itemMemberId, sharedMsgId) case r_ of Just (moderationId, moderatorId, createdByMsgId, moderatedAt) -> do - runExceptT (getGroupMember db vr user groupId moderatorId) >>= \case + runExceptT (getGroupMember db cxt user groupId moderatorId) >>= \case Right moderatorMember -> pure (Just CIModeration {moderationId, moderatorMember, createdByMsgId, moderatedAt}) _ -> pure Nothing _ -> pure Nothing diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index cff3e68234..d432067866 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -380,9 +380,9 @@ createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMo userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff -getUserAddressConnection :: DB.Connection -> VersionRangeChat -> User -> ExceptT StoreError IO Connection -getUserAddressConnection db vr User {userId} = do - ExceptT . firstRow (toConnection vr) SEUserContactLinkNotFound $ +getUserAddressConnection :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO Connection +getUserAddressConnection db cxt User {userId} = do + ExceptT . firstRow (toConnection cxt) SEUserContactLinkNotFound $ DB.query db [sql| @@ -525,8 +525,8 @@ setUserContactLinkShortLink db userContactLinkId shortLink = |] (shortLink, BI True, BI True, BI False, userContactLinkId) -getContactWithoutConnViaAddress :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) -getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do +getContactWithoutConnViaAddress :: DB.Connection -> StoreCxt -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) +getContactWithoutConnViaAddress db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do ctId_ <- maybeFirstRow fromOnly $ DB.query @@ -539,10 +539,10 @@ getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchem WHERE cp.user_id = ? AND cp.contact_link IN (?,?) AND c.connection_id IS NULL |] (userId, cReqSchema1, cReqSchema2) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) ctId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db cxt user) ctId_ -getContactWithoutConnViaShortAddress :: DB.Connection -> VersionRangeChat -> User -> ShortLinkContact -> IO (Maybe Contact) -getContactWithoutConnViaShortAddress db vr user@User {userId} shortLink = do +getContactWithoutConnViaShortAddress :: DB.Connection -> StoreCxt -> User -> ShortLinkContact -> IO (Maybe Contact) +getContactWithoutConnViaShortAddress db cxt user@User {userId} shortLink = do ctId_ <- maybeFirstRow fromOnly $ DB.query @@ -555,7 +555,7 @@ getContactWithoutConnViaShortAddress db vr user@User {userId} shortLink = do WHERE cp.user_id = ? AND cp.contact_link = ? AND c.connection_id IS NULL |] (userId, shortLink) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) ctId_ + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db cxt user) ctId_ updateUserAddressSettings :: DB.Connection -> Int64 -> AddressSettings -> IO () updateUserAddressSettings db userContactLinkId AddressSettings {businessAddress, autoAccept, autoReply} = diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index bd51b10329..f7b525243c 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -228,12 +228,12 @@ type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, BoolInt, May type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe BoolInt, Maybe GroupLinkId, Maybe XContactId) :. (Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe BoolInt, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) -toConnection :: VersionRangeChat -> ConnectionRow -> Connection -toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = +toConnection :: StoreCxt -> ConnectionRow -> Connection +toConnection cxt ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, - connChatVersion = fromMaybe (vr `peerConnChatVersion` peerChatVRange) chatV, + connChatVersion = fromMaybe (vr cxt `peerConnChatVersion` peerChatVRange) chatV, peerChatVRange = peerChatVRange, connLevel, viaContact, @@ -263,9 +263,9 @@ toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI vi entityId_ ConnMember = groupMemberId entityId_ ConnUserContact = userContactLinkId -toMaybeConnection :: VersionRangeChat -> MaybeConnectionRow -> Maybe Connection -toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = - Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) +toMaybeConnection :: StoreCxt -> MaybeConnectionRow -> Maybe Connection +toMaybeConnection cxt ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = + Just $ toConnection cxt ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) toMaybeConnection _ _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> ConnStatus -> VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection @@ -488,10 +488,10 @@ type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe type ContactRow = Only ContactId :. ContactRow' -toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = +toContact :: StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact +toContact cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} - activeConn = toMaybeConnection vr connRow + activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito @@ -673,9 +673,9 @@ type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, Ver type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) -toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} +toGroupInfo :: StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = + let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) @@ -756,9 +756,9 @@ groupMemberQuery = LEFT JOIN connections c ON c.group_member_id = m.group_member_id |] -toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember vr User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} +toContactMember :: StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember cxt User {userContactId} (memberRow :. connRow) = + (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} rowToLocalProfile :: ProfileRow -> LocalProfile rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) = @@ -875,10 +875,10 @@ addGroupChatTags db g@GroupInfo {groupId} = do chatTags <- getGroupChatTags db groupId pure (g :: GroupInfo) {chatTags} -getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo -getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do +getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $ + firstRow (toGroupInfo cxt userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 189f730b67..b4264d121d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -2033,6 +2033,10 @@ type VersionChat = Version ChatVersion type VersionRangeChat = VersionRange ChatVersion +-- | Store-wide context passed to store functions in place of the bare `vr` +-- parameter. Built from config by mkStoreCxt; more fields are added here over time. +newtype StoreCxt = StoreCxt {vr :: VersionRangeChat} + pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 4b28229348..27c36568ec 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -23,7 +23,7 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), mkStoreCxt) import Simplex.Chat.Markdown (viewName) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol @@ -699,10 +699,10 @@ getCtConn cc contactId = getTestCCContact cc contactId >>= maybe (fail "no conne getTestCCContact :: TestCC -> ContactId -> IO Contact getTestCCContact cc contactId = do - let TestCC {chatController = ChatController {config = ChatConfig {chatVRange = vr}}} = cc + let TestCC {chatController = ChatController {config}} = cc withCCTransaction cc $ \db -> withCCUser cc $ \user -> - runExceptT (getContact db vr user contactId) >>= either (fail . show) pure + runExceptT (getContact db (mkStoreCxt config) user contactId) >>= either (fail . show) pure lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do From 95349430c5f334d8560237576f871da25c0f4e46 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 7 Jun 2026 12:01:40 +0100 Subject: [PATCH 04/81] core: support signature verification in p2p groups (forward compatibility) (#7058) * core: support signature verification in p2p groups (forward compatibility) * encoding * mirror encoding * comment --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Library/Subscriber.hs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e25e665bea..87b560d1ab 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3462,8 +3462,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just sm@SignedMsg {chatBinding, signatures, signedBody} | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> case chatBinding of - CBGroup | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> - let prefix = smpEncode chatBinding <> smpEncode (publicGroupId, memberId) + CBGroup -> + let prefix = smpEncode chatBinding <> bindingData + bindingData = case groupKeys gInfo of + Just GroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId) + Nothing -> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups in signed MSSVerified <$ guard (all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) _ -> signed MSSSignedNoKey <$ guard signatureOptional | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) From bf905eb545c23aa0deb22d6d6d3ea05fd1d7d53a Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:38:05 +0000 Subject: [PATCH 05/81] ui: settings navigation reorganization (#7005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * android, desktop: settings navigation reorganization Restructure the root Settings screen into two top-level sections and fold previously-scattered items into three new sub-screens. Root: - Appearance, Your privacy, Chat data - Help & support, Migrate to another device, Advanced settings Your privacy (renamed from Privacy & security): keeps Device, link previews / remove tracking, auto-accept images, blur media, contact requests from groups. Adds a "More privacy" sub-screen. More privacy (new): show last messages, message draft, encrypt local files, protect IP address (with original dynamic footer preserved), notification preview mode (moved from Notifications), and delivery receipts. Help & support (new): merges Help and Support SimpleX Chat sections into Help / About (with App version) / Contact / Support the project. Advanced settings (new): Network & servers, Notifications (Android), Audio & video calls; then Developer tools, Restart and Shutdown (Android), App update channel (desktop). Notifications is hidden on desktop because the screen is empty after Show preview moves to More privacy. Chat data is the new top-level menu label for the existing Database passphrase & export screen. * android, desktop: trim settings reorg diff - Remove 5 dead strings from base/strings.xml (privacy_and_security, database_passphrase_and_export, settings_section_title_chat_database, settings_section_title_support, settings_section_title_app) — no Kotlin references after the reorg. - Drop the now single-variant CurrentPage enum in NotificationsSettingsView; replace with a direct callback. - Read userDisplayName locally in HelpAndSupportView instead of threading it through SettingsLayout/SettingsView/Preview. * android, desktop: remove orphan locale strings Companion to 442a368c9 which removed 5 dead keys from base/strings.xml. The :common:adjustFormatting task enforces that every locale string has a corresponding base entry, so these orphans broke the build. Removed across 35 locale files (154 lines). Keys removed: - privacy_and_security - database_passphrase_and_export - settings_section_title_chat_database - settings_section_title_support - settings_section_title_app * Revert "android, desktop: remove orphan locale strings" This reverts commit 0ad5fc9308a3599d15bf50ca1980002ae0e467b2. * android, desktop: restore base strings for removed keys Counterpart to revert of 0ad5fc930: re-add the 5 base entries that 442a368c9 had deleted so the locale files (restored by the prior revert) are no longer orphaned. Translation keys must not be removed once introduced — the values can change but the keys stay. Keys restored to base with their master English values: - database_passphrase_and_export - privacy_and_security - settings_section_title_chat_database - settings_section_title_support - settings_section_title_app * android, desktop: keep share-button helpers in SettingsView ContributeItem, RateAppItem, StarOnGithubItem were moved from SettingsView.kt to HelpAndSupportView.kt as part of the reorg. Move them back: just drop the `private` modifier (one-word edit per function) so HelpAndSupportView can call them in place. Saves ~60 lines of diff churn vs the move + matches the file's existing pattern where helpers like AppVersionItem, ChatPreferencesItem, ChatLockItem, etc. are all public top-level @Composable. * android, desktop: inline HelpAndSupportView into SettingsView.kt HelpAndSupportView is the only call site of SettingsView.kt's share-button helpers; placing it as a top-level @Composable in SettingsView.kt keeps the help/about/contact/support flow next to the other settings entry points and removes the need for a new file. Three imports (BuildConfigCommon, SimpleXInfo, WhatsNewView) that the reorg was deleting from SettingsView.kt stay in place. Saves ~35 lines of diff and one new file. * android, desktop: inline AdvancedSettingsView into SettingsView.kt Same treatment as HelpAndSupportView in the previous commit: AdvancedSettingsView is only reached from SettingsLayout, so the function and its expect declaration live as top-level @Composable in SettingsView.kt instead of a new file. NetworkAndServersView import that the reorg was deleting from SettingsView.kt stays. The .android.kt / .desktop.kt actuals are unchanged and keep implementing the (now relocated) expect. Saves ~15 lines and a file. * ios: settings navigation reorganization Mirror the multiplatform reorg on iOS: Root SettingsView: collapse the 5 sections into 2 unlabeled groups — {Appearance, Your privacy, Chat data} and {Help & support, Migrate to another device, Advanced settings}. "Privacy & security" becomes "Your privacy"; the database row label becomes "Chat data". PrivacySettings: keeps Device, link previews / remove tracking, auto-accept images, blur media, contact requests from groups. Adds a "More privacy" link. MorePrivacy (new, inlined in PrivacySettings.swift): show last messages, message draft, encrypt local files, protect IP address (with original dynamic footer preserved), notification preview mode (moved from NotificationsView), delivery receipts. Own state and private helpers for the moved set* functions. HelpAndSupportView (new, inlined in SettingsView.swift): merges Help and Support sections into Help / About (with App version) / Contact / Support the project. AdvancedSettingsView (new, inlined in SettingsView.swift): Network & servers, Notifications, Audio & video calls, Developer tools. iOS has no Restart/Shutdown (Android-only) or App update channel (desktop). NotificationsView: "Show preview" navigation removed — it now lives in MorePrivacy. notificationsIcon() promoted to a free function so AdvancedSettingsView can render the notifications status badge. * android, desktop: keep platform file names as SettingsView.{android,desktop}.kt Revert the file renames from {Advanced}SettingsView.{android,desktop}.kt. Function rename SettingsSectionApp → AdvancedSettingsAppSection stays inside each file; only the file path returns to its original name. No behavior change; diff stat now shows two in-place modifications instead of renames. * ios: keep PrivacySettings/SettingsView state in place, use inline destinations Restructure the iOS reorg to avoid moving state, helpers, and the alert enum out of PrivacySettings — and to avoid moving notificationsIcon out of SettingsView. The Help & Support, Advanced Settings, and More Privacy "screens" become private computed properties on their parent struct, so all @AppStorage, @State, set* helpers, and the PrivacySettingsViewAlert enum stay UNCHANGED from master. NavigationLink destinations reference the computed properties directly. Net iOS diff vs master: 220+/154- (was 361+/259-) — saves ~245 lines. * simplex settings * android, desktop: mirror iOS settings reorganization - inline Advanced settings section into the main settings list (Network & servers, Notifications, Audio & video calls, App version); remove the separate Advanced settings page - reorder first section: Appearance, Your privacy, Help & support, Chat data, Migrate; merge About SimpleX Chat into the Help section - move the developer/maintenance section under App version (VersionInfoView); load core version inside the view so it always opens (Developer tools and Shutdown stay reachable even if the version request fails) - keep "Developer tools" label (not renamed to "Developer") - replace the Restart row with Cancel/Restart/Shutdown options in the Shutdown dialog - split DatabaseView: "Chat data" page (messages TTL, Database passphrase & export, Files & media) and a sub-page with passphrase/export/import/delete and the Run chat toggle; rename title to "Chat data" - align delivery receipts alert wording with the renamed "Your privacy" settings * android, desktop: simplify settings reorg internals - VersionInfoView: drop the section/card wrapping, keep the original plain version-text layout; load core version in-view so the screen always opens - DatabaseView: make the "Database passphrase & export" sub-page a self-contained DatabaseManagementView that owns its own state, mirroring the DatabaseView/DatabaseLayout pattern instead of threading params through a modal * android, desktop: show App version screen as a card screen VersionInfoView now hosts a settings section (Developer tools / updates), so open it with cardScreen = true like the other settings screens — otherwise the section renders without the card box around it. * android, desktop: show "Rate the app" only on mobile The action opens a Play Store link, which does nothing on desktop (the market:// scheme has no handler and the web fallback never fires). Gate it to Android, like the Contribute item. * android, desktop: move Shutdown to settings above app version Move the Shutdown action out of the app version screen into the main advanced settings section, just above the app version row. It stays Android only (desktop is closed via the window) through an AppShutdownItem expect/actual. * android, desktop: show app version info in its own card Wrap the version info block on the app version screen in a section card, matching iOS and the rest of the card-screen settings. * fix background --------- Co-authored-by: Evgeny Poberezkin --- .../Shared/Views/Database/DatabaseView.swift | 149 ++++----- .../NetworkAndServers/NetworkAndServers.swift | 10 - .../UserSettings/NotificationsView.swift | 30 -- .../Views/UserSettings/PrivacySettings.swift | 196 +++++++----- .../SetDeliveryReceiptsView.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 147 ++++----- .../Views/UserSettings/VersionView.swift | 28 +- .../ar.xcloc/Localized Contents/ar.xliff | 4 +- .../bg.xcloc/Localized Contents/bg.xliff | 4 +- .../bn.xcloc/Localized Contents/bn.xliff | 4 +- .../cs.xcloc/Localized Contents/cs.xliff | 4 +- .../de.xcloc/Localized Contents/de.xliff | 4 +- .../el.xcloc/Localized Contents/el.xliff | 4 +- .../en.xcloc/Localized Contents/en.xliff | 6 +- .../es.xcloc/Localized Contents/es.xliff | 4 +- .../fi.xcloc/Localized Contents/fi.xliff | 4 +- .../fr.xcloc/Localized Contents/fr.xliff | 4 +- .../he.xcloc/Localized Contents/he.xliff | 4 +- .../hr.xcloc/Localized Contents/hr.xliff | 4 +- .../hu.xcloc/Localized Contents/hu.xliff | 4 +- .../it.xcloc/Localized Contents/it.xliff | 4 +- .../ja.xcloc/Localized Contents/ja.xliff | 4 +- .../ko.xcloc/Localized Contents/ko.xliff | 4 +- .../lt.xcloc/Localized Contents/lt.xliff | 4 +- .../nl.xcloc/Localized Contents/nl.xliff | 4 +- .../pl.xcloc/Localized Contents/pl.xliff | 4 +- .../Localized Contents/pt-BR.xliff | 4 +- .../pt.xcloc/Localized Contents/pt.xliff | 4 +- .../ru.xcloc/Localized Contents/ru.xliff | 4 +- .../th.xcloc/Localized Contents/th.xliff | 4 +- .../tr.xcloc/Localized Contents/tr.xliff | 4 +- .../uk.xcloc/Localized Contents/uk.xliff | 4 +- .../Localized Contents/zh-Hans.xliff | 4 +- .../Localized Contents/zh-Hant.xliff | 4 +- apps/ios/SimpleX SE/ShareModel.swift | 2 +- .../SimpleX SE/de.lproj/Localizable.strings | 2 +- .../SimpleX SE/es.lproj/Localizable.strings | 2 +- .../SimpleX SE/fr.lproj/Localizable.strings | 2 +- .../SimpleX SE/hu.lproj/Localizable.strings | 2 +- .../SimpleX SE/it.lproj/Localizable.strings | 2 +- .../SimpleX SE/nl.lproj/Localizable.strings | 2 +- .../SimpleX SE/pl.lproj/Localizable.strings | 2 +- .../SimpleX SE/ru.lproj/Localizable.strings | 2 +- .../SimpleX SE/tr.lproj/Localizable.strings | 2 +- .../SimpleX SE/uk.lproj/Localizable.strings | 2 +- .../zh-Hans.lproj/Localizable.strings | 2 +- apps/ios/bg.lproj/Localizable.strings | 6 +- apps/ios/cs.lproj/Localizable.strings | 6 +- apps/ios/de.lproj/Localizable.strings | 6 +- apps/ios/es.lproj/Localizable.strings | 6 +- apps/ios/fi.lproj/Localizable.strings | 6 +- apps/ios/fr.lproj/Localizable.strings | 6 +- apps/ios/hu.lproj/Localizable.strings | 6 +- apps/ios/it.lproj/Localizable.strings | 6 +- apps/ios/ja.lproj/Localizable.strings | 6 +- apps/ios/nl.lproj/Localizable.strings | 6 +- apps/ios/pl.lproj/Localizable.strings | 6 +- apps/ios/product/README.md | 2 +- apps/ios/product/views/settings.md | 8 +- apps/ios/ru.lproj/Localizable.strings | 6 +- apps/ios/th.lproj/Localizable.strings | 6 +- apps/ios/tr.lproj/Localizable.strings | 6 +- apps/ios/uk.lproj/Localizable.strings | 6 +- apps/ios/zh-Hans.lproj/Localizable.strings | 6 +- .../usersettings/SettingsView.android.kt | 47 ++- .../common/views/database/DatabaseView.kt | 282 ++++++++++-------- .../usersettings/NotificationsSettingsView.kt | 31 +- .../views/usersettings/PrivacySettings.kt | 157 ++++++---- .../common/views/usersettings/SettingsView.kt | 91 +++--- .../views/usersettings/VersionInfoView.kt | 54 +++- .../commonMain/resources/MR/base/strings.xml | 9 +- .../usersettings/SettingsView.desktop.kt | 20 +- 72 files changed, 835 insertions(+), 658 deletions(-) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index d5d70abaea..278893a669 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -110,33 +110,88 @@ struct DatabaseView: View { } Section { - settingsRow( - stopped ? "exclamationmark.octagon.fill" : "play.fill", - color: stopped ? .red : .green - ) { - Toggle( - stopped ? "Chat is stopped" : "Chat is running", - isOn: $runChat - ) - .onChange(of: runChat) { _ in - if runChat { - DatabaseView.startChat($runChat, $progressIndicator) - } else if !stoppingChat { - stoppingChat = false - alert = .stopChat - } - } - } - } header: { - Text("Run chat") - .foregroundColor(theme.colors.secondary) - } footer: { - if case .documents = dbContainer { - Text("Database will be migrated when the app restarts") - .foregroundColor(theme.colors.secondary) - } + NavigationLink("Database passphrase & export", destination: databaseManagementView) } + Section { + Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { + alert = .deleteFilesAndMedia + } + .disabled(progressIndicator || appFilesCountAndSize?.0 == 0) + } header: { + Text("Files & media") + .foregroundColor(theme.colors.secondary) + } footer: { + if let (fileCount, size) = appFilesCountAndSize { + if fileCount == 0 { + Text("No received or sent files") + .foregroundColor(theme.colors.secondary) + } else { + Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))") + .foregroundColor(theme.colors.secondary) + } + } + } + } + .onAppear { + runChat = m.chatRunning ?? true + appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory()) + currentChatItemTTL = chatItemTTL + } + .onChange(of: chatItemTTL) { ttl in + if ttl < currentChatItemTTL { + alert = .setChatItemTTL(ttl: ttl) + } else if ttl != currentChatItemTTL { + setCiTTL(ttl) + } + } + .alert(item: $alert) { item in databaseAlert(item) } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.zip], + allowsMultipleSelection: false + ) { result in + if case let .success(files) = result, let fileURL = files.first { + importedArchivePath = fileURL + alert = .importArchive + } + } + } + + private func runChatToggleView() -> some View { + Section { + let stopped = m.chatRunning == false + settingsRow( + stopped ? "exclamationmark.octagon.fill" : "play.fill", + color: stopped ? .red : .green + ) { + Toggle( + stopped ? "Chat is stopped" : "Chat is running", + isOn: $runChat + ) + .onChange(of: runChat) { _ in + if runChat { + DatabaseView.startChat($runChat, $progressIndicator) + } else if !stoppingChat { + stoppingChat = false + alert = .stopChat + } + } + } + } header: { + Text("Run chat") + .foregroundColor(theme.colors.secondary) + } footer: { + if case .documents = dbContainer { + Text("Database will be migrated when the app restarts") + .foregroundColor(theme.colors.secondary) + } + } + } + + private func databaseManagementView() -> some View { + List { + let stopped = m.chatRunning == false Section { let unencrypted = m.chatDbEncrypted == false let color: Color = unencrypted ? .orange : theme.colors.secondary @@ -194,49 +249,9 @@ struct DatabaseView: View { } } - Section { - Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { - alert = .deleteFilesAndMedia - } - .disabled(progressIndicator || appFilesCountAndSize?.0 == 0) - } header: { - Text("Files & media") - .foregroundColor(theme.colors.secondary) - } footer: { - if let (fileCount, size) = appFilesCountAndSize { - if fileCount == 0 { - Text("No received or sent files") - .foregroundColor(theme.colors.secondary) - } else { - Text("\(fileCount) file(s) with total size of \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .binary))") - .foregroundColor(theme.colors.secondary) - } - } - } - } - .onAppear { - runChat = m.chatRunning ?? true - appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory()) - currentChatItemTTL = chatItemTTL - } - .onChange(of: chatItemTTL) { ttl in - if ttl < currentChatItemTTL { - alert = .setChatItemTTL(ttl: ttl) - } else if ttl != currentChatItemTTL { - setCiTTL(ttl) - } - } - .alert(item: $alert) { item in databaseAlert(item) } - .fileImporter( - isPresented: $showFileImporter, - allowedContentTypes: [.zip], - allowsMultipleSelection: false - ) { result in - if case let .success(files) = result, let fileURL = files.first { - importedArchivePath = fileURL - alert = .importArchive - } + runChatToggleView() } + .modifier(ThemedBackground(grouped: true)) } private func databaseAlert(_ alertItem: DatabaseAlert) -> Alert { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index f10b945dc0..24cf088918 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -121,16 +121,6 @@ struct NetworkAndServers: View { } } - Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { - NavigationLink { - RTCServers() - .navigationTitle("Your ICE servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("WebRTC ICE servers") - } - } - Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) { HStack { Text(m.networkInfo.networkType.text) diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index c4d0588987..131eeecef7 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -63,36 +63,6 @@ struct NotificationsView: View { } } - NavigationLink { - List { - Section { - SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in - ntfPreviewModeGroupDefault.set(previewMode) - m.notificationPreview = previewMode - } - } footer: { - VStack(alignment: .leading, spacing: 1) { - Text("You can set lock screen notification preview via settings.") - .foregroundColor(theme.colors.secondary) - Button("Open Settings") { - DispatchQueue.main.async { - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) - } - } - } - } - } - .navigationTitle("Show preview") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.inline) - } label: { - HStack { - Text("Show preview") - Spacer() - Text(m.notificationPreview.label) - } - } - if let server = m.notificationServer { smpServers("Push server", [server], theme.colors.secondary) testTokenButton(server) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 3ae9f0eacd..ad6b2d4454 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -81,30 +81,12 @@ struct PrivacySettings: View { settingsRow("link", color: theme.colors.secondary) { Toggle("Remove link tracking", isOn: $privacySanitizeLinks) } - settingsRow("message", color: theme.colors.secondary) { - Toggle("Show last messages", isOn: $showChatPreviews) - } - settingsRow("rectangle.and.pencil.and.ellipsis", color: theme.colors.secondary) { - Toggle("Message draft", isOn: $saveLastDraft) - } - .onChange(of: saveLastDraft) { saveDraft in - if !saveDraft { - m.draft = nil - m.draftChatId = nil - } - } } header: { Text("Chats") .foregroundColor(theme.colors.secondary) } Section { - settingsRow("lock.doc", color: theme.colors.secondary) { - Toggle("Encrypt local files", isOn: $encryptLocalFiles) - .onChange(of: encryptLocalFiles) { - setEncryptLocalFiles($0) - } - } settingsRow("photo", color: theme.colors.secondary) { Toggle("Auto-accept images", isOn: $autoAcceptImages) .onChange(of: autoAcceptImages) { @@ -126,20 +108,9 @@ struct PrivacySettings: View { } } } - settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) { - Toggle("Protect IP address", isOn: $askToApproveRelays) - } } header: { Text("Files") .foregroundColor(theme.colors.secondary) - } footer: { - if askToApproveRelays { - Text("The app will ask to confirm downloads from unknown file servers (except .onion).") - .foregroundColor(theme.colors.secondary) - } else { - Text("Without Tor or VPN, your IP address will be visible to file servers.") - .foregroundColor(theme.colors.secondary) - } } Section { @@ -155,45 +126,8 @@ struct PrivacySettings: View { } Section { - settingsRow("person", color: theme.colors.secondary) { - Toggle("Contacts", isOn: $contactReceipts) - } - settingsRow("person.2", color: theme.colors.secondary) { - Toggle("Small groups (max 20)", isOn: $groupReceipts) - } - } header: { - Text("Send delivery receipts to") - .foregroundColor(theme.colors.secondary) - } footer: { - VStack(alignment: .leading) { - Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") - Text("They can be overridden in contact and group settings.") - } - .foregroundColor(theme.colors.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .confirmationDialog(contactReceiptsDialogTitle, isPresented: $contactReceiptsDialogue, titleVisibility: .visible) { - Button(contactReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { - setSendReceiptsContacts(contactReceipts, clearOverrides: false) - } - Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) { - setSendReceiptsContacts(contactReceipts, clearOverrides: true) - } - Button("Cancel", role: .cancel) { - contactReceiptsReset = true - contactReceipts.toggle() - } - } - .confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) { - Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { - setSendReceiptsGroups(groupReceipts, clearOverrides: false) - } - Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) { - setSendReceiptsGroups(groupReceipts, clearOverrides: true) - } - Button("Cancel", role: .cancel) { - groupReceiptsReset = true - groupReceipts.toggle() + NavigationLink(destination: morePrivacyView) { + settingsRow("ellipsis", color: theme.colors.secondary) { Text("More privacy") } } } } @@ -243,6 +177,132 @@ struct PrivacySettings: View { } } + @ViewBuilder + private func morePrivacyView() -> some View { + List { + Section { + settingsRow("message", color: theme.colors.secondary) { + Toggle("Show last messages", isOn: $showChatPreviews) + } + settingsRow("rectangle.and.pencil.and.ellipsis", color: theme.colors.secondary) { + Toggle("Message draft", isOn: $saveLastDraft) + } + .onChange(of: saveLastDraft) { saveDraft in + if !saveDraft { + m.draft = nil + m.draftChatId = nil + } + } + } header: { + Text("Chats") + .foregroundColor(theme.colors.secondary) + } + + Section { + settingsRow("lock.doc", color: theme.colors.secondary) { + Toggle("Encrypt local files", isOn: $encryptLocalFiles) + .onChange(of: encryptLocalFiles) { + setEncryptLocalFiles($0) + } + } + settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) { + Toggle("Protect IP address", isOn: $askToApproveRelays) + } + } header: { + Text("Files") + .foregroundColor(theme.colors.secondary) + } footer: { + if askToApproveRelays { + Text("The app will ask to confirm downloads from unknown file servers (except .onion).") + .foregroundColor(theme.colors.secondary) + } else { + Text("Without Tor or VPN, your IP address will be visible to file servers.") + .foregroundColor(theme.colors.secondary) + } + } + + Section { + NavigationLink { + List { + Section { + SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) { previewMode in + ntfPreviewModeGroupDefault.set(previewMode) + m.notificationPreview = previewMode + } + } footer: { + VStack(alignment: .leading, spacing: 1) { + Text("You can set lock screen notification preview via settings.") + .foregroundColor(theme.colors.secondary) + Button("Open Settings") { + DispatchQueue.main.async { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) + } + } + } + } + } + .navigationTitle("Show preview") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.inline) + } label: { + HStack { + Text("Show preview") + Spacer() + Text(m.notificationPreview.label) + } + } + } header: { + Text("Notifications") + .foregroundColor(theme.colors.secondary) + } + + Section { + settingsRow("person", color: theme.colors.secondary) { + Toggle("Contacts", isOn: $contactReceipts) + } + settingsRow("person.2", color: theme.colors.secondary) { + Toggle("Small groups (max 20)", isOn: $groupReceipts) + } + } header: { + Text("Send delivery receipts to") + .foregroundColor(theme.colors.secondary) + } footer: { + VStack(alignment: .leading) { + Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") + Text("They can be overridden in contact and group settings.") + } + .foregroundColor(theme.colors.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .confirmationDialog(contactReceiptsDialogTitle, isPresented: $contactReceiptsDialogue, titleVisibility: .visible) { + Button(contactReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { + setSendReceiptsContacts(contactReceipts, clearOverrides: false) + } + Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) { + setSendReceiptsContacts(contactReceipts, clearOverrides: true) + } + Button("Cancel", role: .cancel) { + contactReceiptsReset = true + contactReceipts.toggle() + } + } + .confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) { + Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") { + setSendReceiptsGroups(groupReceipts, clearOverrides: false) + } + Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) { + setSendReceiptsGroups(groupReceipts, clearOverrides: true) + } + Button("Cancel", role: .cancel) { + groupReceiptsReset = true + groupReceipts.toggle() + } + } + } + .navigationTitle("More privacy") + .modifier(ThemedBackground(grouped: true)) + } + private func setEncryptLocalFiles(_ enable: Bool) { do { try apiSetEncryptLocalFiles(enable) diff --git a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift index e03dace43d..e46edbc5af 100644 --- a/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SetDeliveryReceiptsView.swift @@ -69,7 +69,7 @@ struct SetDeliveryReceiptsView: View { Button { AlertManager.shared.showAlert(Alert( title: Text("Delivery receipts are disabled!"), - message: Text("You can enable them later via app Privacy & Security settings."), + message: Text("You can enable them later via app Your privacy settings."), primaryButton: .default(Text("Don't show again")) { m.setDeliveryReceipts = false privacyDeliveryReceiptsSet.set(true) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a903329454..93f32a53a6 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -290,47 +290,7 @@ struct SettingsView: View { func settingsView() -> some View { List { - let user = chatModel.currentUser - Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { - NavigationLink { - NotificationsView() - .navigationTitle("Notifications") - .modifier(ThemedBackground(grouped: true)) - } label: { - HStack { - notificationsIcon() - Text("Notifications") - } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - NetworkAndServers() - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - CallSettings() - .navigationTitle("Your calls") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } - } - .disabled(chatModel.chatRunning != true) - - NavigationLink { - PrivacySettings() - .navigationTitle("Your privacy") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("lock", color: theme.colors.secondary) { Text("Privacy & security") } - } - .disabled(chatModel.chatRunning != true) - + Section(header: Text(verbatim: "").foregroundColor(theme.colors.secondary)) { if UIApplication.shared.supportsAlternateIcons { NavigationLink { AppearanceSettings() @@ -341,10 +301,24 @@ struct SettingsView: View { } .disabled(chatModel.chatRunning != true) } - } - Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) { + NavigationLink { + PrivacySettings() + .navigationTitle("Your privacy") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("lock", color: theme.colors.secondary) { Text("Your privacy") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + helpAndSupportView + } label: { + settingsRow("questionmark", color: theme.colors.secondary) { Text("Help & support") } + } + chatDatabaseRow() + NavigationLink { MigrateFromDevice(showProgressOnSettings: $showProgress) .toolbar { @@ -360,6 +334,58 @@ struct SettingsView: View { } } + Section(header: Text("Advanced settings").foregroundColor(theme.colors.secondary)) { + NavigationLink { + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + NotificationsView() + .navigationTitle("Notifications") + .modifier(ThemedBackground(grouped: true)) + } label: { + HStack { + notificationsIcon() + Text("Notifications") + } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + CallSettings() + .navigationTitle("Your calls") + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("video", color: theme.colors.secondary) { Text("Audio & video calls") } + } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + VersionView() + .navigationBarTitle("App version") + .modifier(ThemedBackground()) + } label: { + Text(verbatim: "v\(appVersion ?? "?")") + } + } + } + .navigationTitle("Your settings") + .modifier(ThemedBackground(grouped: true)) + .onDisappear { + chatModel.showingTerminal = false + chatModel.terminalItems = [] + } + } + + @ViewBuilder + private var helpAndSupportView: some View { + List { + let user = chatModel.currentUser Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { if let user = user { NavigationLink { @@ -378,6 +404,7 @@ struct SettingsView: View { } label: { settingsRow("plus", color: theme.colors.secondary) { Text("What's new") } } + NavigationLink { SimpleXInfo(onboarding: false) .navigationBarTitle("", displayMode: .inline) @@ -386,6 +413,9 @@ struct SettingsView: View { } label: { settingsRow("info", color: theme.colors.secondary) { Text("About SimpleX Chat") } } + } + + Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) { settingsRow("number", color: theme.colors.secondary) { Button("Send questions and ideas") { dismiss() @@ -398,7 +428,7 @@ struct SettingsView: View { settingsRow("envelope", color: theme.colors.secondary) { Text("[Send us email](mailto:chat@simplex.chat)") } } - Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) { + Section(header: Text("Support the project").foregroundColor(theme.colors.secondary)) { settingsRow("keyboard", color: theme.colors.secondary) { ExternalLink("Contribute", destination: URL(string: "https://github.com/simplex-chat/simplex-chat#contribute")!) } @@ -421,42 +451,21 @@ struct SettingsView: View { } } } - - Section(header: Text("Develop").foregroundColor(theme.colors.secondary)) { - NavigationLink { - DeveloperView() - .navigationTitle("Developer tools") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) { Text("Developer tools") } - } - NavigationLink { - VersionView() - .navigationBarTitle("App version") - .modifier(ThemedBackground()) - } label: { - Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") - } - } } - .navigationTitle("Your settings") + .navigationTitle("Help & support") .modifier(ThemedBackground(grouped: true)) - .onDisappear { - chatModel.showingTerminal = false - chatModel.terminalItems = [] - } } - + private func chatDatabaseRow() -> some View { NavigationLink { DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL) - .navigationTitle("Your chat database") + .navigationTitle("Chat data") .modifier(ThemedBackground(grouped: true)) } label: { let color: Color = chatModel.chatDbEncrypted == false ? .orange : theme.colors.secondary settingsRow("internaldrive", color: color) { HStack { - Text("Database passphrase & export") + Text("Chat data") Spacer() if chatModel.chatRunning == false { Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) diff --git a/apps/ios/Shared/Views/UserSettings/VersionView.swift b/apps/ios/Shared/Views/UserSettings/VersionView.swift index 0fc2b4cb3e..e30c11699e 100644 --- a/apps/ios/Shared/Views/UserSettings/VersionView.swift +++ b/apps/ios/Shared/Views/UserSettings/VersionView.swift @@ -10,21 +10,33 @@ import SwiftUI import SimpleXChat struct VersionView: View { + @EnvironmentObject var theme: AppTheme @State var versionInfo: CoreVersionInfo? var body: some View { - VStack(alignment: .leading) { - Text("App version: v\(appVersion ?? "?")") - Text("App build: \(appBuild ?? "?")") - if let info = versionInfo { - Text("Core version: v\(info.version)") - if let v = try? AttributedString(markdown: "simplexmq: v\(info.simplexmqVersion) ([\(info.simplexmqCommit.prefix(7))](https://github.com/simplex-chat/simplexmq/commit/\(info.simplexmqCommit)))") { - Text(v) + List { + Section { + Text("App version: v\(appVersion ?? "?")") + Text("App build: \(appBuild ?? "?")") + if let info = versionInfo { + Text("Core version: v\(info.version)") + if let v = try? AttributedString(markdown: "simplexmq: v\(info.simplexmqVersion) ([\(info.simplexmqCommit.prefix(7))](https://github.com/simplex-chat/simplexmq/commit/\(info.simplexmqCommit)))") { + Text(v) + } + } + } + + Section { + NavigationLink { + DeveloperView() + .navigationTitle("Developer") + .modifier(ThemedBackground(grouped: true)) + } label: { + Text("Developer") } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding() .onAppear { do { versionInfo = try apiGetVersion() diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index 427430b833..bd8d6a17ef 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -1157,8 +1157,8 @@ يطور No comment provided by engineer. - - Developer tools + + Developer أدوات المطور No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 364cee97e5..89d5014c95 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -3014,8 +3014,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer Инструменти за разработчици No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff index fbda1abd29..bdb0c0ce24 100644 --- a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff +++ b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff @@ -1223,8 +1223,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 5ba29ec846..f7beecf4ba 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2904,8 +2904,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer Nástroje pro vývojáře No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 797a489c92..822c13649e 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -3130,8 +3130,8 @@ alert button Optionen für Entwickler No comment provided by engineer. - - Developer tools + + Developer Entwicklertools No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff index 7a560bb41b..181f51eb99 100644 --- a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff +++ b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff @@ -1100,8 +1100,8 @@ Available in v5.1 Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index c108dcc904..bd498d53e3 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -3140,9 +3140,9 @@ alert button Developer options No comment provided by engineer. - - Developer tools - Developer tools + + Developer + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index d93e692a63..60de758c9c 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -3130,8 +3130,8 @@ alert button Opciones desarrollador No comment provided by engineer. - - Developer tools + + Developer Herramientas desarrollo No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 5656516b7d..c818fdc472 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2791,8 +2791,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer Kehittäjätyökalut No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 3ea0859d76..4d1616df20 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -3039,8 +3039,8 @@ alert button Options pour les développeurs No comment provided by engineer. - - Developer tools + + Developer Outils du développeur No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff index f94d6cefd8..a22d30dd73 100644 --- a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff +++ b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff @@ -1356,8 +1356,8 @@ Available in v5.1 לְפַתֵחַ No comment provided by engineer. - - Developer tools + + Developer כלי מפתחים No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index 2aa945f603..33fe9a0e9c 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -1012,8 +1012,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 129436ecb0..11b447dc2b 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -3130,8 +3130,8 @@ alert button Fejlesztői beállítások No comment provided by engineer. - - Developer tools + + Developer Fejlesztői eszközök No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 469da88ce2..876cdb10bb 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -3130,8 +3130,8 @@ alert button Opzioni sviluppatore No comment provided by engineer. - - Developer tools + + Developer Strumenti di sviluppo No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 13396b13a4..ff0d085c08 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2891,8 +2891,8 @@ alert button 開発者向けの設定 No comment provided by engineer. - - Developer tools + + Developer 開発ツール No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index ca51a875c7..01b5e2b9a2 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -1141,8 +1141,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff index 4b51d66a34..5aa2a98854 100644 --- a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff +++ b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff @@ -1005,8 +1005,8 @@ Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 9f1818fba9..a7670a4475 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -3040,8 +3040,8 @@ alert button Ontwikkelaars opties No comment provided by engineer. - - Developer tools + + Developer Ontwikkel gereedschap No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 2644708927..a60b5891d4 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -3063,8 +3063,8 @@ alert button Opcje deweloperskie No comment provided by engineer. - - Developer tools + + Developer Narzędzia deweloperskie No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff index d9af0624bf..032a33ff62 100644 --- a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff +++ b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff @@ -1179,8 +1179,8 @@ Desenvolver No comment provided by engineer. - - Developer tools + + Developer Ferramentas de desenvolvimento No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff index e4fac55bcb..3905130e84 100644 --- a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff +++ b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff @@ -1203,8 +1203,8 @@ Available in v5.1 Develop No comment provided by engineer. - - Developer tools + + Developer No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index a3971c0325..22df6680f6 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -3130,8 +3130,8 @@ alert button Опции разработчика No comment provided by engineer. - - Developer tools + + Developer Инструменты разработчика No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index cd2e30977d..11d6b28091 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2779,8 +2779,8 @@ alert button Developer options No comment provided by engineer. - - Developer tools + + Developer เครื่องมือสำหรับนักพัฒนา No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 1189b53e3c..b673b609b9 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -3066,8 +3066,8 @@ alert button Geliştirici seçenekleri No comment provided by engineer. - - Developer tools + + Developer Geliştirici araçları No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 49f9e21eda..3c0c6ef8de 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -3050,8 +3050,8 @@ alert button Можливості для розробників No comment provided by engineer. - - Developer tools + + Developer Інструменти для розробників No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 8823f17204..9001375615 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -3060,8 +3060,8 @@ alert button 开发者选项 No comment provided by engineer. - - Developer tools + + Developer 开发者工具 No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff index 0e4e383b52..ebd6f4da71 100644 --- a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff @@ -1148,8 +1148,8 @@ 開發 No comment provided by engineer. - - Developer tools + + Developer 開發者工具 No comment provided by engineer. diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 18f3e2c344..9790e5944f 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -75,7 +75,7 @@ class ShareModel: ObservableObject { func setup(context: NSExtensionContext) { if appLocalAuthEnabledGroupDefault.get() && !allowShareExtensionGroupDefault.get() { - errorAlert = ErrorAlert(title: "App is locked!", message: "You can allow sharing in Privacy & Security / SimpleX Lock settings.") + errorAlert = ErrorAlert(title: "App is locked!", message: "You can allow sharing in Your privacy / SimpleX Lock settings.") return } if let item = context.inputItems.first as? NSExtensionItem, diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index df368686e8..1d2e5a4b01 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Falsches Datenbank-Passwort"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Sie können das Teilen in den Einstellungen zu Datenschutz & Sicherheit / SimpleX-Sperre erlauben."; diff --git a/apps/ios/SimpleX SE/es.lproj/Localizable.strings b/apps/ios/SimpleX SE/es.lproj/Localizable.strings index 4cc5029537..5bb42b4f0a 100644 --- a/apps/ios/SimpleX SE/es.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/es.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Contraseña incorrecta de la base de datos"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Puedes dar permiso para compartir en Privacidad y Seguridad / Bloque SimpleX."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Puedes dar permiso para compartir en Privacidad y Seguridad / Bloque SimpleX."; diff --git a/apps/ios/SimpleX SE/fr.lproj/Localizable.strings b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings index 46a458b471..6b492ee882 100644 --- a/apps/ios/SimpleX SE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Mauvaise phrase secrète pour la base de données"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Vous pouvez autoriser le partage dans les paramètres Confidentialité et sécurité / SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Vous pouvez autoriser le partage dans les paramètres Confidentialité et sécurité / SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 3aad39c5d1..dae06b1d73 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "A megosztást az Adatvédelem és biztonság / SimpleX-zár menüben engedélyezheti."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "A megosztást az Adatvédelem és biztonság / SimpleX-zár menüben engedélyezheti."; diff --git a/apps/ios/SimpleX SE/it.lproj/Localizable.strings b/apps/ios/SimpleX SE/it.lproj/Localizable.strings index e3d34650a3..3b27092f24 100644 --- a/apps/ios/SimpleX SE/it.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/it.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Password del database sbagliata"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Puoi consentire la condivisione in Privacy e sicurezza / impostazioni di SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Puoi consentire la condivisione in Privacy e sicurezza / impostazioni di SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/nl.lproj/Localizable.strings b/apps/ios/SimpleX SE/nl.lproj/Localizable.strings index e5d2487b54..050cb1a735 100644 --- a/apps/ios/SimpleX SE/nl.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/nl.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Verkeerde database wachtwoord"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "U kunt delen toestaan in de instellingen voor Privacy en beveiliging / SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "U kunt delen toestaan in de instellingen voor Privacy en beveiliging / SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/pl.lproj/Localizable.strings b/apps/ios/SimpleX SE/pl.lproj/Localizable.strings index c563431c28..0755e1e374 100644 --- a/apps/ios/SimpleX SE/pl.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/pl.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Nieprawidłowe hasło bazy danych"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Możesz zezwolić na udostępnianie w ustawieniach Prywatność i bezpieczeństwo / Blokada SimpleX."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Możesz zezwolić na udostępnianie w ustawieniach Prywatność i bezpieczeństwo / Blokada SimpleX."; diff --git a/apps/ios/SimpleX SE/ru.lproj/Localizable.strings b/apps/ios/SimpleX SE/ru.lproj/Localizable.strings index e4c8c000d4..90a5841738 100644 --- a/apps/ios/SimpleX SE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/ru.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Неправильный пароль базы данных"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Вы можете разрешить функцию Поделиться в настройках Конфиденциальности / Блокировка SimpleX."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Вы можете разрешить функцию Поделиться в настройках Конфиденциальности / Блокировка SimpleX."; diff --git a/apps/ios/SimpleX SE/tr.lproj/Localizable.strings b/apps/ios/SimpleX SE/tr.lproj/Localizable.strings index baef71c127..69b122832d 100644 --- a/apps/ios/SimpleX SE/tr.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/tr.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Yanlış veritabanı parolası"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Gizlilik ve Güvenlik / SimpleX Lock ayarlarından paylaşıma izin verebilirsiniz."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Gizlilik ve Güvenlik / SimpleX Lock ayarlarından paylaşıma izin verebilirsiniz."; diff --git a/apps/ios/SimpleX SE/uk.lproj/Localizable.strings b/apps/ios/SimpleX SE/uk.lproj/Localizable.strings index a6da81185e..7574470d01 100644 --- a/apps/ios/SimpleX SE/uk.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/uk.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "Неправильна ключова фраза до бази даних"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "Ви можете дозволити спільний доступ у налаштуваннях Конфіденційність і безпека / SimpleX Lock."; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "Ви можете дозволити спільний доступ у налаштуваннях Конфіденційність і безпека / SimpleX Lock."; diff --git a/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings b/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings index 362e2edb74..fc046ab087 100644 --- a/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/zh-Hans.lproj/Localizable.strings @@ -107,5 +107,5 @@ "Wrong database passphrase" = "数据库密码错误"; /* No comment provided by engineer. */ -"You can allow sharing in Privacy & Security / SimpleX Lock settings." = "您可以在 \"隐私与安全\"/\"SimpleX Lock \"设置中允许共享。"; +"You can allow sharing in Your privacy / SimpleX Lock settings." = "您可以在 \"隐私与安全\"/\"SimpleX Lock \"设置中允许共享。"; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index ec869e05b4..68f2cfe502 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1659,7 +1659,7 @@ alert button */ "Develop" = "Разработване"; /* No comment provided by engineer. */ -"Developer tools" = "Инструменти за разработчици"; +"Developer" = "Инструменти за разработчици"; /* No comment provided by engineer. */ "Device" = "Устройство"; @@ -3217,7 +3217,7 @@ alert button */ "Preview" = "Визуализация"; /* No comment provided by engineer. */ -"Privacy & security" = "Поверителност и сигурност"; +"Your privacy" = "Поверителност и сигурност"; /* No comment provided by engineer. */ "Private filenames" = "Поверителни имена на файлове"; @@ -4452,7 +4452,7 @@ server test failure */ "You can enable later via Settings" = "Можете да активирате по-късно през Настройки"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Можете да ги активирате по-късно през настройките за \"Поверителност и сигурност\" на приложението."; +"You can enable them later via app Your privacy settings." = "Можете да ги активирате по-късно през настройките за \"Поверителност и сигурност\" на приложението."; /* No comment provided by engineer. */ "You can give another try." = "Можете да опитате още веднъж."; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index fc4b3f0fc6..0b74ef9504 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1306,7 +1306,7 @@ alert button */ "Develop" = "Vyvinout"; /* No comment provided by engineer. */ -"Developer tools" = "Nástroje pro vývojáře"; +"Developer" = "Nástroje pro vývojáře"; /* No comment provided by engineer. */ "Device" = "Zařízení"; @@ -2578,7 +2578,7 @@ alert button */ "Preview" = "Náhled"; /* No comment provided by engineer. */ -"Privacy & security" = "Ochrana osobních údajů a zabezpečení"; +"Your privacy" = "Ochrana osobních údajů a zabezpečení"; /* No comment provided by engineer. */ "Private filenames" = "Soukromé názvy souborů"; @@ -3543,7 +3543,7 @@ server test failure */ "You can enable later via Settings" = "Můžete povolit později v Nastavení"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Můžete je povolit později v nastavení Soukromí & Bezpečnosti aplikace"; +"You can enable them later via app Your privacy settings." = "Můžete je povolit později v nastavení Soukromí & Bezpečnosti aplikace"; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "Profil uživatele můžete skrýt nebo ztlumit - přejeďte prstem doprava."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 2c4e37791b..80246b727b 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Optionen für Entwickler"; /* No comment provided by engineer. */ -"Developer tools" = "Entwicklertools"; +"Developer" = "Entwicklertools"; /* No comment provided by engineer. */ "Device" = "Gerät"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Bisher verbundene Server"; /* No comment provided by engineer. */ -"Privacy & security" = "Datenschutz & Sicherheit"; +"Your privacy" = "Datenschutz & Sicherheit"; /* No comment provided by engineer. */ "Privacy for your customers." = "Schutz der Privatsphäre Ihrer Kunden."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Sie können diese später in den Einstellungen aktivieren"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Sie können diese später in den Datenschutz & Sicherheits-Einstellungen der App aktivieren."; +"You can enable them later via app Your privacy settings." = "Sie können diese später in den Datenschutz & Sicherheits-Einstellungen der App aktivieren."; /* No comment provided by engineer. */ "You can give another try." = "Sie können es nochmal probieren."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index cf03ae6dbf..e0611c0afc 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Opciones desarrollador"; /* No comment provided by engineer. */ -"Developer tools" = "Herramientas desarrollo"; +"Developer" = "Herramientas desarrollo"; /* No comment provided by engineer. */ "Device" = "Dispositivo"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Servidores conectados previamente"; /* No comment provided by engineer. */ -"Privacy & security" = "Seguridad y Privacidad"; +"Your privacy" = "Seguridad y Privacidad"; /* No comment provided by engineer. */ "Privacy for your customers." = "Privacidad para tus clientes."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Puedes activar más tarde en Configuración"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Puedes activarlos más tarde en la configuración de Privacidad y Seguridad."; +"You can enable them later via app Your privacy settings." = "Puedes activarlos más tarde en la configuración de Privacidad y Seguridad."; /* No comment provided by engineer. */ "You can give another try." = "Puedes intentarlo de nuevo."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 3b1bd6523c..36b516374e 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -982,7 +982,7 @@ alert button */ "Develop" = "Kehitä"; /* No comment provided by engineer. */ -"Developer tools" = "Kehittäjätyökalut"; +"Developer" = "Kehittäjätyökalut"; /* No comment provided by engineer. */ "Device" = "Laite"; @@ -2232,7 +2232,7 @@ new chat action */ "Preview" = "Esikatselu"; /* No comment provided by engineer. */ -"Privacy & security" = "Yksityisyys ja turvallisuus"; +"Your privacy" = "Yksityisyys ja turvallisuus"; /* No comment provided by engineer. */ "Private filenames" = "Yksityiset tiedostonimet"; @@ -3179,7 +3179,7 @@ server test failure */ "You can enable later via Settings" = "Voit ottaa käyttöön myöhemmin asetusten kautta"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista."; +"You can enable them later via app Your privacy settings." = "Voit ottaa ne käyttöön myöhemmin sovelluksen Yksityisyys & Turvallisuus -asetuksista."; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "Voit piilottaa tai mykistää käyttäjäprofiilin pyyhkäisemällä sitä oikealle."; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 91cd6f3078..98cdb4aec8 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1745,7 +1745,7 @@ alert button */ "Developer options" = "Options pour les développeurs"; /* No comment provided by engineer. */ -"Developer tools" = "Outils du développeur"; +"Developer" = "Outils du développeur"; /* No comment provided by engineer. */ "Device" = "Appareil"; @@ -3742,7 +3742,7 @@ alert button */ "Previously connected servers" = "Serveurs précédemment connectés"; /* No comment provided by engineer. */ -"Privacy & security" = "Vie privée et sécurité"; +"Your privacy" = "Vie privée et sécurité"; /* No comment provided by engineer. */ "Privacy for your customers." = "Respect de la vie privée de vos clients."; @@ -5448,7 +5448,7 @@ server test failure */ "You can enable later via Settings" = "Vous pouvez l'activer ultérieurement via Paramètres"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Vous pouvez les activer ultérieurement via les paramètres de Confidentialité et Sécurité de l'application."; +"You can enable them later via app Your privacy settings." = "Vous pouvez les activer ultérieurement via les paramètres de Confidentialité et Sécurité de l'application."; /* No comment provided by engineer. */ "You can give another try." = "Vous pouvez faire un nouvel essai."; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 029fb9edd5..362d9157fd 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Fejlesztői beállítások"; /* No comment provided by engineer. */ -"Developer tools" = "Fejlesztői eszközök"; +"Developer" = "Fejlesztői eszközök"; /* No comment provided by engineer. */ "Device" = "Eszköz"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Korábban kapcsolódott kiszolgálók"; /* No comment provided by engineer. */ -"Privacy & security" = "Adatvédelem és biztonság"; +"Your privacy" = "Adatvédelem és biztonság"; /* No comment provided by engineer. */ "Privacy for your customers." = "Saját ügyfeleinek adatvédelme."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Később engedélyezheti a beállításokban"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Később engedélyezheti őket az „Adatvédelem és biztonság” menüben."; +"You can enable them later via app Your privacy settings." = "Később engedélyezheti őket az „Adatvédelem és biztonság” menüben."; /* No comment provided by engineer. */ "You can give another try." = "Megpróbálhatja még egyszer."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index c882eb662c..8d4fa3532b 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Opzioni sviluppatore"; /* No comment provided by engineer. */ -"Developer tools" = "Strumenti di sviluppo"; +"Developer" = "Strumenti di sviluppo"; /* No comment provided by engineer. */ "Device" = "Dispositivo"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Server precedentemente connessi"; /* No comment provided by engineer. */ -"Privacy & security" = "Privacy e sicurezza"; +"Your privacy" = "Privacy e sicurezza"; /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy per i tuoi clienti."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Puoi attivarle più tardi nelle impostazioni"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Puoi attivarle più tardi nelle impostazioni di privacy e sicurezza dell'app."; +"You can enable them later via app Your privacy settings." = "Puoi attivarle più tardi nelle impostazioni di privacy e sicurezza dell'app."; /* No comment provided by engineer. */ "You can give another try." = "Puoi fare un altro tentativo."; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 35d8732e3f..b88eb10414 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -1270,7 +1270,7 @@ alert button */ "Developer options" = "開発者向けの設定"; /* No comment provided by engineer. */ -"Developer tools" = "開発ツール"; +"Developer" = "開発ツール"; /* No comment provided by engineer. */ "Device" = "端末"; @@ -2533,7 +2533,7 @@ alert button */ "Preview" = "プレビュー"; /* No comment provided by engineer. */ -"Privacy & security" = "プライバシーとセキュリティ"; +"Your privacy" = "プライバシーとセキュリティ"; /* No comment provided by engineer. */ "Private filenames" = "プライベートなファイル名"; @@ -3459,7 +3459,7 @@ server test failure */ "You can enable later via Settings" = "あとで設定から有効にできます"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。"; +"You can enable them later via app Your privacy settings." = "あとでアプリのプライバシーとセキュリティの設定から有効にすることができます。"; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "ユーザープロファイルを右にスワイプすると、非表示またはミュートにすることができます。"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 407665bbec..1c776a7bbe 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1773,7 +1773,7 @@ alert button */ "Developer options" = "Ontwikkelaars opties"; /* No comment provided by engineer. */ -"Developer tools" = "Ontwikkel gereedschap"; +"Developer" = "Ontwikkel gereedschap"; /* No comment provided by engineer. */ "Device" = "Apparaat"; @@ -3929,7 +3929,7 @@ alert button */ "Previously connected servers" = "Eerder verbonden servers"; /* No comment provided by engineer. */ -"Privacy & security" = "Privacy en beveiliging"; +"Your privacy" = "Privacy en beveiliging"; /* No comment provided by engineer. */ "Privacy for your customers." = "Privacy voor uw klanten."; @@ -5777,7 +5777,7 @@ server test failure */ "You can enable later via Settings" = "U kunt later inschakelen via Instellingen"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "U kunt ze later inschakelen via de privacy- en beveiligingsinstellingen van de app."; +"You can enable them later via app Your privacy settings." = "U kunt ze later inschakelen via de privacy- en beveiligingsinstellingen van de app."; /* No comment provided by engineer. */ "You can give another try." = "Je kunt het nog een keer proberen."; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 8905300160..9cc6eca252 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1845,7 +1845,7 @@ alert button */ "Developer options" = "Opcje deweloperskie"; /* No comment provided by engineer. */ -"Developer tools" = "Narzędzia deweloperskie"; +"Developer" = "Narzędzia deweloperskie"; /* No comment provided by engineer. */ "Device" = "Urządzenie"; @@ -4131,7 +4131,7 @@ alert button */ "Previously connected servers" = "Wcześniej połączone serwery"; /* No comment provided by engineer. */ -"Privacy & security" = "Prywatność i bezpieczeństwo"; +"Your privacy" = "Prywatność i bezpieczeństwo"; /* No comment provided by engineer. */ "Privacy for your customers." = "Prywatność dla Twoich klientów."; @@ -6135,7 +6135,7 @@ server test failure */ "You can enable later via Settings" = "Możesz włączyć później w Ustawieniach"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Możesz je włączyć później w ustawieniach Prywatności i Bezpieczeństwa aplikacji."; +"You can enable them later via app Your privacy settings." = "Możesz je włączyć później w ustawieniach Prywatności i Bezpieczeństwa aplikacji."; /* No comment provided by engineer. */ "You can give another try." = "Możesz spróbować ponownie."; diff --git a/apps/ios/product/README.md b/apps/ios/product/README.md index 107c0e6569..fd25b09d01 100644 --- a/apps/ios/product/README.md +++ b/apps/ios/product/README.md @@ -101,7 +101,7 @@ End-to-end encrypted audio and video communication. | Call history | Call events displayed as chat items | `Shared/Views/Chat/ChatItem/CICallItemView.swift` | | Incoming call view | Dedicated UI for incoming call notifications | `Shared/Views/Call/IncomingCallView.swift` | -### 5. Privacy & Security +### 5. Your privacy Encryption, authentication, and privacy controls. diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md index 3cc4da5d2b..7e7f653910 100644 --- a/apps/ios/product/views/settings.md +++ b/apps/ios/product/views/settings.md @@ -4,7 +4,7 @@ ## Purpose -Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker sheet on the chat list. +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and Developer. Accessed from the UserPicker sheet on the chat list. ## Route / Navigation @@ -22,7 +22,7 @@ Configure all aspects of app behavior including notifications, network/servers, | Notifications | `bolt` (color varies by token status) | `NotificationsView` | Push notification mode and preview settings | | Network & servers | `externaldrive.connected.to.line.below` | `NetworkAndServers` | SMP/XFTP servers, proxy, .onion hosts, advanced network | | Audio & video calls | `video` | `CallSettings` | WebRTC relay policy, ICE servers, CallKit options | -| Privacy & security | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | +| Your privacy | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | | Appearance | `sun.max` | `AppearanceSettings` | Theme, language, wallpapers, chat bubbles, toolbar opacity | All rows disabled when `chatModel.chatRunning != true`. Appearance row only shown when `UIApplication.shared.supportsAlternateIcons`. @@ -77,7 +77,7 @@ Adding a relay: `NewChatRelayView` form with name, address, test, and enable tog Server validation (`validateServers_`) now returns both errors and warnings. -#### Privacy & Security (`PrivacySettings`) +#### Your privacy (`PrivacySettings`) | Setting | Description | |---|---| @@ -152,7 +152,7 @@ Database row shows exclamation octagon icon in red when `chatRunning == false`. | Row | Icon | Destination | Description | |---|---|---|---| -| Developer tools | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | +| Developer | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | | App version | (none) | `VersionView` | Shows "v{version} ({build})" | ## Loading / Error States diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 9f79b5dea0..042359dd7e 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -2064,7 +2064,7 @@ alert button */ "Developer options" = "Опции разработчика"; /* No comment provided by engineer. */ -"Developer tools" = "Инструменты разработчика"; +"Developer" = "Разработчик"; /* No comment provided by engineer. */ "Device" = "Устройство"; @@ -4533,7 +4533,7 @@ alert button */ "Previously connected servers" = "Ранее подключенные серверы"; /* No comment provided by engineer. */ -"Privacy & security" = "Конфиденциальность"; +"Your privacy" = "Конфиденциальность"; /* No comment provided by engineer. */ "Privacy for your customers." = "Конфиденциальность для ваших покупателей."; @@ -6759,7 +6759,7 @@ server test failure */ "You can enable later via Settings" = "Вы можете включить их позже в Настройках"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Вы можете включить их позже в настройках Конфиденциальности."; +"You can enable them later via app Your privacy settings." = "Вы можете включить их позже в настройках Конфиденциальности."; /* No comment provided by engineer. */ "You can give another try." = "Вы можете попробовать ещё раз."; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index cc3abea189..62bf4d3d59 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -946,7 +946,7 @@ alert button */ "Develop" = "พัฒนา"; /* No comment provided by engineer. */ -"Developer tools" = "เครื่องมือสำหรับนักพัฒนา"; +"Developer" = "เครื่องมือสำหรับนักพัฒนา"; /* No comment provided by engineer. */ "Device" = "อุปกรณ์"; @@ -2172,7 +2172,7 @@ new chat action */ "Preview" = "ดูตัวอย่าง"; /* No comment provided by engineer. */ -"Privacy & security" = "ความเป็นส่วนตัวและความปลอดภัย"; +"Your privacy" = "ความเป็นส่วนตัวและความปลอดภัย"; /* No comment provided by engineer. */ "Private filenames" = "ชื่อไฟล์ส่วนตัว"; @@ -3089,7 +3089,7 @@ server test failure */ "You can enable later via Settings" = "คุณสามารถเปิดใช้งานในภายหลังผ่านการตั้งค่า"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "คุณสามารถเปิดใช้งานได้ในภายหลังผ่านการตั้งค่าความเป็นส่วนตัวและความปลอดภัยของแอป"; +"You can enable them later via app Your privacy settings." = "คุณสามารถเปิดใช้งานได้ในภายหลังผ่านการตั้งค่าความเป็นส่วนตัวและความปลอดภัยของแอป"; /* No comment provided by engineer. */ "You can hide or mute a user profile - swipe it to the right." = "คุณสามารถซ่อนหรือปิดเสียงโปรไฟล์ผู้ใช้ - ปัดไปทางขวา"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index e06989afee..8dfc23078c 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1859,7 +1859,7 @@ alert button */ "Developer options" = "Geliştirici seçenekleri"; /* No comment provided by engineer. */ -"Developer tools" = "Geliştirici araçları"; +"Developer" = "Geliştirici araçları"; /* No comment provided by engineer. */ "Device" = "Cihaz"; @@ -4099,7 +4099,7 @@ alert button */ "Previously connected servers" = "Önceden bağlanılmış sunucular"; /* No comment provided by engineer. */ -"Privacy & security" = "Gizlilik & güvenlik"; +"Your privacy" = "Gizlilik & güvenlik"; /* No comment provided by engineer. */ "Privacy for your customers." = "Müşterileriniz için gizlilik."; @@ -6064,7 +6064,7 @@ server test failure */ "You can enable later via Settings" = "Daha sonra Ayarlardan etkinleştirebilirsin"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Daha sonra uygulamanın Gizlilik ve Güvenlik ayarlarından etkinleştirebilirsiniz."; +"You can enable them later via app Your privacy settings." = "Daha sonra uygulamanın Gizlilik ve Güvenlik ayarlarından etkinleştirebilirsiniz."; /* No comment provided by engineer. */ "You can give another try." = "Bir kez daha deneyebilirsiniz."; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 4a21eb4ae8..ff2f64e2ed 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1806,7 +1806,7 @@ alert button */ "Developer options" = "Можливості для розробників"; /* No comment provided by engineer. */ -"Developer tools" = "Інструменти для розробників"; +"Developer" = "Інструменти для розробників"; /* No comment provided by engineer. */ "Device" = "Пристрій"; @@ -4019,7 +4019,7 @@ alert button */ "Previously connected servers" = "Раніше підключені сервери"; /* No comment provided by engineer. */ -"Privacy & security" = "Конфіденційність і безпека"; +"Your privacy" = "Конфіденційність і безпека"; /* No comment provided by engineer. */ "Privacy for your customers." = "Конфіденційність для ваших клієнтів."; @@ -5966,7 +5966,7 @@ server test failure */ "You can enable later via Settings" = "Ви можете увімкнути пізніше в Налаштуваннях"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "Ви можете увімкнути їх пізніше в налаштуваннях конфіденційності та безпеки програми."; +"You can enable them later via app Your privacy settings." = "Ви можете увімкнути їх пізніше в налаштуваннях конфіденційності та безпеки програми."; /* No comment provided by engineer. */ "You can give another try." = "Ви можете спробувати ще раз."; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 13be5125ea..f3138230f9 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1836,7 +1836,7 @@ alert button */ "Developer options" = "开发者选项"; /* No comment provided by engineer. */ -"Developer tools" = "开发者工具"; +"Developer" = "开发者工具"; /* No comment provided by engineer. */ "Device" = "设备"; @@ -4107,7 +4107,7 @@ alert button */ "Previously connected servers" = "以前连接的服务器"; /* No comment provided by engineer. */ -"Privacy & security" = "隐私和安全"; +"Your privacy" = "隐私和安全"; /* No comment provided by engineer. */ "Privacy for your customers." = "客户隐私。"; @@ -6093,7 +6093,7 @@ server test failure */ "You can enable later via Settings" = "您可以稍后在设置中启用它"; /* No comment provided by engineer. */ -"You can enable them later via app Privacy & Security settings." = "您可以稍后通过应用程序的 \"隐私与安全 \"设置启用它们。"; +"You can enable them later via app Your privacy settings." = "您可以稍后通过应用程序的 \"隐私与安全 \"设置启用它们。"; /* No comment provided by engineer. */ "You can give another try." = "你可以再试一次。"; diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt index 04b59732dd..5e9706d713 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -1,7 +1,15 @@ package chat.simplex.common.views.usersettings +import SectionItemView import SectionView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* @@ -11,19 +19,19 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -actual fun SettingsSectionApp( +actual fun AdvancedSettingsAppSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showVersion: () -> Unit, - withAuth: (title: String, desc: String, block: () -> Unit) -> Unit + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) { - SectionView(stringResource(MR.strings.settings_section_title_app)) { - SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp) - SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) }) + SectionView { SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }) - AppVersionItem(showVersion) } } +@Composable +actual fun AppShutdownItem() { + SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), ::shutdownAppAlert) +} fun restartApp() { ProcessPhoenix.triggerRebirth(androidAppContext) @@ -36,11 +44,28 @@ private fun shutdownApp() { Runtime.getRuntime().exit(0) } -private fun shutdownAppAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( +private fun shutdownAppAlert() { + AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.shutdown_alert_question), text = generalGetString(MR.strings.shutdown_alert_desc), - destructive = true, - onConfirm = onConfirm + buttons = { + Column { + SectionItemView({ AlertManager.shared.hideAlert() }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + } + SectionItemView({ + AlertManager.shared.hideAlert() + restartApp() + }) { + Text(stringResource(MR.strings.settings_restart_app), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + shutdownApp() + }) { + Text(stringResource(MR.strings.settings_shutdown), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + } + } ) } 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 648a0eb8e7..80f97d1caf 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 @@ -44,29 +44,8 @@ fun DatabaseView() { val prefs = m.controller.appPrefs val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } - val chatArchiveFile = remember { mutableStateOf(null) } val stopped = remember { m.chatRunning }.value == false - val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? -> - val archive = chatArchiveFile.value - if (archive != null && to != null) { - copyFileToFile(File(archive), to) {} - } - // delete no matter the database was exported or canceled the export process - if (archive != null) { - File(archive).delete() - chatArchiveFile.value = null - } - } val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } - val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> - if (to != null) { - importArchiveAlert { - stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - importArchive(to, appFilesCountAndSize, progressIndicator, false) - } - } - } - } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } Box( Modifier.fillMaxSize(), @@ -79,27 +58,10 @@ fun DatabaseView() { useKeychain.value, m.chatDbEncrypted.value, m.controller.appPrefs.storeDBPassphrase.state.value, - m.controller.appPrefs.initialRandomDBPassphrase, - importArchiveLauncher, appFilesCountAndSize, chatItemTTL, user, m.users, - startChat = { startChat(m, chatLastStart, m.chatDbChanged, progressIndicator) }, - stopChatAlert = { stopChatAlert(m, progressIndicator) }, - exportArchive = { - stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - exportArchive(m, progressIndicator, chatArchiveFile, saveArchiveLauncher) - } - }, - deleteChatAlert = { - deleteChatAlert { - stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - deleteChat(m, progressIndicator) - true - } - } - }, deleteAppFilesAndMedia = { deleteFilesAndMediaAlert { stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { @@ -120,12 +82,9 @@ fun DatabaseView() { setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } }, - disconnectAllHosts = { - val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected } - connected.forEachIndexed { index, h -> - controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote()) - } - } + showDatabaseManagement = { + ModalManager.start.showModal(cardScreen = true) { DatabaseManagementView() } + }, ) if (progressIndicator.value) { Box( @@ -151,24 +110,18 @@ fun DatabaseLayout( useKeyChain: Boolean, chatDbEncrypted: Boolean?, passphraseSaved: Boolean, - initialRandomDBPassphrase: SharedPreference, - importArchiveLauncher: FileChooserLauncher, appFilesCountAndSize: MutableState>, chatItemTTL: MutableState, currentUser: User?, users: List, - startChat: () -> Unit, - stopChatAlert: () -> Unit, - exportArchive: () -> Unit, - deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, onChatItemTTLSelected: (ChatItemTTL?) -> Unit, - disconnectAllHosts: () -> Unit, + showDatabaseManagement: () -> Unit, ) { val operationsDisabled = progressIndicator && !chatModel.desktopNoUserNoRemote ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.your_chat_database)) + AppBarTitle(stringResource(MR.strings.chat_data)) if (!chatModel.desktopNoUserNoRemote) { SectionView(stringResource(MR.strings.messages_section_title)) { @@ -187,79 +140,17 @@ fun DatabaseLayout( ) SectionDividerSpaced() } - val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } - if (chatModel.localUserCreated.value == true) { - // still show the toggle in case database was stopped when the user opened this screen because it can be in the following situations: - // - database was stopped after migration and the app relaunched - // - something wrong happened with database operations and the database couldn't be launched when it should - SectionView(stringResource(MR.strings.run_chat_section)) { - if (!toggleEnabled) { - SectionItemView(disconnectAllHosts) { - Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) - } - } - RunChatSetting(stopped, toggleEnabled && !progressIndicator, startChat, stopChatAlert) - } - if (stopped) SectionTextFooter(stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)) - SectionDividerSpaced() - } - SectionView(stringResource(MR.strings.chat_database_section)) { - if (chatModel.localUserCreated.value != true && !toggleEnabled) { - SectionItemView(disconnectAllHosts) { - Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) - } - } + SectionView { val unencrypted = chatDbEncrypted == false SettingsActionItem( if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), - stringResource(MR.strings.database_passphrase), - click = { ModalManager.start.showModal(cardScreen = true) { DatabaseEncryptionView(chatModel, false) } }, + stringResource(MR.strings.database_passphrase_and_export), + click = showDatabaseManagement, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) - if (appPlatform.isDesktop) { - SettingsActionItem( - painterResource(MR.images.ic_folder_open), - stringResource(MR.strings.open_database_folder), - ::desktopOpenDatabaseDir, - disabled = operationsDisabled - ) - } - SettingsActionItem( - painterResource(MR.images.ic_ios_share), - stringResource(MR.strings.export_database), - click = { - if (initialRandomDBPassphrase.get()) { - exportProhibitedAlert() - ModalManager.start.showModal { - DatabaseEncryptionView(chatModel, false) - } - } else { - exportArchive() - } - }, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary, - disabled = operationsDisabled - ) - SettingsActionItem( - painterResource(MR.images.ic_download), - stringResource(MR.strings.import_database), - { withLongRunningApi { importArchiveLauncher.launch("application/zip") } }, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) - SettingsActionItem( - painterResource(MR.images.ic_delete_forever), - stringResource(MR.strings.delete_database), - deleteChatAlert, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) } SectionDividerSpaced() @@ -287,6 +178,155 @@ fun DatabaseLayout( } } +@Composable +fun DatabaseManagementView() { + val m = chatModel + val progressIndicator = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } + val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } + val chatArchiveFile = remember { mutableStateOf(null) } + val stopped = remember { m.chatRunning }.value == false + val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? -> + val archive = chatArchiveFile.value + if (archive != null && to != null) { + copyFileToFile(File(archive), to) {} + } + // delete no matter the database was exported or canceled the export process + if (archive != null) { + File(archive).delete() + chatArchiveFile.value = null + } + } + val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } + val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) { + importArchiveAlert { + stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { + importArchive(to, appFilesCountAndSize, progressIndicator, false) + } + } + } + } + val operationsDisabled = progressIndicator.value && !m.desktopNoUserNoRemote + + Box(Modifier.fillMaxSize()) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.database_passphrase_and_export)) + + val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } + val disconnectAllHosts = { + val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected } + connected.forEachIndexed { index, h -> + controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote()) + } + } + SectionView(stringResource(MR.strings.chat_database_section)) { + if (chatModel.localUserCreated.value != true && !toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + val unencrypted = m.chatDbEncrypted.value == false + SettingsActionItem( + if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeychain.value) painterResource(MR.images.ic_vpn_key_filled) + else painterResource(MR.images.ic_lock), + stringResource(MR.strings.database_passphrase), + click = { ModalManager.start.showModal(cardScreen = true) { DatabaseEncryptionView(chatModel, false) } }, + iconColor = if (unencrypted || (appPlatform.isDesktop && prefs.storeDBPassphrase.state.value)) WarningOrange else MaterialTheme.colors.secondary, + disabled = operationsDisabled + ) + if (appPlatform.isDesktop) { + SettingsActionItem( + painterResource(MR.images.ic_folder_open), + stringResource(MR.strings.open_database_folder), + ::desktopOpenDatabaseDir, + disabled = operationsDisabled + ) + } + SettingsActionItem( + painterResource(MR.images.ic_ios_share), + stringResource(MR.strings.export_database), + click = { + if (prefs.initialRandomDBPassphrase.get()) { + exportProhibitedAlert() + ModalManager.start.showModal { + DatabaseEncryptionView(chatModel, false) + } + } else { + stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { + exportArchive(m, progressIndicator, chatArchiveFile, saveArchiveLauncher) + } + } + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = operationsDisabled + ) + SettingsActionItem( + painterResource(MR.images.ic_download), + stringResource(MR.strings.import_database), + { withLongRunningApi { importArchiveLauncher.launch("application/zip") } }, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + SettingsActionItem( + painterResource(MR.images.ic_delete_forever), + stringResource(MR.strings.delete_database), + { + deleteChatAlert { + stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { + deleteChat(m, progressIndicator) + true + } + } + }, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + } + + if (chatModel.localUserCreated.value == true) { + SectionDividerSpaced() + // still show the toggle in case database was stopped when the user opened this screen because it can be in the following situations: + // - database was stopped after migration and the app relaunched + // - something wrong happened with database operations and the database couldn't be launched when it should + SectionView(stringResource(MR.strings.run_chat_section)) { + if (!toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + RunChatSetting( + stopped, + toggleEnabled && !progressIndicator.value, + startChat = { startChat(m, chatLastStart, m.chatDbChanged, progressIndicator) }, + stopChatAlert = { stopChatAlert(m, progressIndicator) } + ) + } + if (stopped) SectionTextFooter(stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)) + } + SectionBottomSpacer() + } + if (progressIndicator.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.5.dp + ) + } + } + } +} + private fun setChatItemTTLAlert( m: ChatModel, rhId: Long?, selectedChatItemTTL: MutableState, progressIndicator: MutableState, @@ -832,19 +872,13 @@ fun PreviewDatabaseLayout() { useKeyChain = false, chatDbEncrypted = false, passphraseSaved = false, - initialRandomDBPassphrase = SharedPreference({ true }, {}), - importArchiveLauncher = rememberFileChooserLauncher(true) {}, appFilesCountAndSize = remember { mutableStateOf(0 to 0L) }, chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) }, currentUser = User.sampleData, users = listOf(UserInfo.sampleData), - startChat = {}, - stopChatAlert = {}, - exportArchive = {}, - deleteChatAlert = {}, deleteAppFilesAndMedia = {}, onChatItemTTLSelected = {}, - disconnectAllHosts = {}, + showDatabaseManagement = {}, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 150b2a38e0..91324bb39a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -24,43 +24,28 @@ import kotlin.collections.ArrayList fun NotificationsSettingsView( chatModel: ChatModel, ) { - val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode -> - chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name) - chatModel.notificationPreviewMode.value = mode - } - NotificationsSettingsLayout( notificationsMode = remember { chatModel.controller.appPrefs.notificationsMode.state }, - notificationPreviewMode = chatModel.notificationPreviewMode, - showPage = { page -> + showNotificationsMode = { ModalManager.start.showModalCloseable(true) { - when (page) { - CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.controller.appPrefs.notificationsMode.state) { changeNotificationsMode(it, chatModel) } - CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected) - } + NotificationsModeView(chatModel.controller.appPrefs.notificationsMode.state) { changeNotificationsMode(it, chatModel) } } }, ) } -enum class CurrentPage { - NOTIFICATIONS_MODE, NOTIFICATION_PREVIEW_MODE -} - @Composable fun NotificationsSettingsLayout( notificationsMode: State, - notificationPreviewMode: State, - showPage: (CurrentPage) -> Unit, + showNotificationsMode: () -> Unit, ) { val modes = remember { notificationModes() } - val previewModes = remember { notificationPreviewModes() } ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.notifications)) SectionView(null) { if (appPlatform == AppPlatform.ANDROID) { - SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), { showPage(CurrentPage.NOTIFICATIONS_MODE) }) { + SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), showNotificationsMode) { Text( modes.firstOrNull { it.value == notificationsMode.value }?.title ?: "", maxLines = 1, @@ -69,14 +54,6 @@ fun NotificationsSettingsLayout( ) } } - SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notification_preview_mode_title), { showPage(CurrentPage.NOTIFICATION_PREVIEW_MODE) }) { - Text( - previewModes.firstOrNull { it.value == notificationPreviewMode.value }?.title ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colors.secondary - ) - } } if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) 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 7316c9bd82..cf34fd5a44 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp @@ -73,6 +74,48 @@ fun PrivacySettingsView( stringResource(MR.strings.sanitize_links_toggle), chatModel.controller.appPrefs.privacySanitizeLinks ) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_files)) { + SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) + BlurRadiusOptions(remember { appPrefs.privacyMediaBlurRadius.state }) { + appPrefs.privacyMediaBlurRadius.set(it) + } + } + + val currentUser = chatModel.currentUser.value + if (currentUser != null && !chatModel.desktopNoUserNoRemote) { + SectionDividerSpaced() + ContacRequestsFromGroupsSection( + currentUser = currentUser, + setAutoAcceptGrpDirectInvs = { enable -> + withApi { + chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable) + chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable) + } + } + ) + } + + SectionDividerSpaced() + SectionView { + SettingsActionItem( + painterResource(MR.images.ic_more_horiz), + stringResource(MR.strings.more_privacy), + showSettingsModal { MorePrivacyView(it) } + ) + } + SectionBottomSpacer() + } +} + +@Composable +fun MorePrivacyView(chatModel: ChatModel) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.more_privacy)) + + SectionView(stringResource(MR.strings.settings_section_title_chats)) { SettingsPreferenceItem( painterResource(MR.images.ic_chat_bubble), stringResource(MR.strings.privacy_show_last_messages), @@ -98,10 +141,6 @@ fun PrivacySettingsView( 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) - BlurRadiusOptions(remember { appPrefs.privacyMediaBlurRadius.state }) { - appPrefs.privacyMediaBlurRadius.set(it) - } SettingsPreferenceItem(painterResource(MR.images.ic_security), stringResource(MR.strings.protect_ip_address), chatModel.controller.appPrefs.privacyAskToApproveRelays) } SectionTextFooter( @@ -111,9 +150,34 @@ fun PrivacySettingsView( stringResource(MR.strings.without_tor_or_vpn_ip_address_will_be_visible_to_file_servers) } ) + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.notifications)) { + val previewModes = remember { notificationPreviewModes() } + val notificationPreviewMode = remember { chatModel.notificationPreviewMode } + SettingsActionItemWithContent( + painterResource(MR.images.ic_visibility_off), + stringResource(MR.strings.settings_notification_preview_mode_title), + click = { + ModalManager.start.showModalCloseable(true) { + NotificationPreviewView(notificationPreviewMode) { mode -> + chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name) + chatModel.notificationPreviewMode.value = mode + } + } + } + ) { + Text( + previewModes.firstOrNull { it.value == notificationPreviewMode.value }?.title ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.secondary + ) + } + } val currentUser = chatModel.currentUser.value - if (currentUser != null) { + if (currentUser != null && !chatModel.desktopNoUserNoRemote) { fun setSendReceiptsContacts(enable: Boolean, clearOverrides: Boolean) { withLongRunningApi(slow = 60_000) { val mrs = UserMsgReceiptSettings(enable, clearOverrides) @@ -164,57 +228,40 @@ fun PrivacySettingsView( } } - fun setAutoAcceptGrpDirectInvs(enable: Boolean) { - withApi { - chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable) - chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable) + SectionDividerSpaced() + DeliveryReceiptsSection( + currentUser = currentUser, + setOrAskSendReceiptsContacts = { enable -> + val contactReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Direct) { + val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (contactReceiptsOverrides == 0) { + setSendReceiptsContacts(enable, clearOverrides = false) + } else { + showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) + } + }, + setOrAskSendReceiptsGroups = { enable -> + val groupReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Group) { + val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (groupReceiptsOverrides == 0) { + setSendReceiptsGroups(enable, clearOverrides = false) + } else { + showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) + } } - } - - if (!chatModel.desktopNoUserNoRemote) { - SectionDividerSpaced() - ContacRequestsFromGroupsSection( - currentUser = currentUser, - setAutoAcceptGrpDirectInvs = { enable -> - setAutoAcceptGrpDirectInvs(enable) - } - ) - - SectionDividerSpaced() - DeliveryReceiptsSection( - currentUser = currentUser, - setOrAskSendReceiptsContacts = { enable -> - val contactReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Direct) { - val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) - } else { - count - } - } - if (contactReceiptsOverrides == 0) { - setSendReceiptsContacts(enable, clearOverrides = false) - } else { - showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) - } - }, - setOrAskSendReceiptsGroups = { enable -> - val groupReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Group) { - val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) - } else { - count - } - } - if (groupReceiptsOverrides == 0) { - setSendReceiptsGroups(enable, clearOverrides = false) - } else { - showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) - } - } - ) - } + ) } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index f17d3a6e4b..c0b822dbb4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -37,17 +37,15 @@ import chat.simplex.res.MR @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { - val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false + val showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) = { modalView -> { ModalManager.start.showModal(settings = true, cardScreen = true) { modalView(chatModel) } } } SettingsLayout( stopped, chatModel.chatDbEncrypted.value == true, remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, - remember { chatModel.controller.appPrefs.notificationsMode.state }, - user?.displayName, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.start.showModal(settings = true, cardScreen = true) { modalView(chatModel) } } }, + showSettingsModal = showSettingsModal, showSettingsModalWithSearch = { modalView -> ModalManager.start.showCustomModal { close -> val search = rememberSaveable { mutableStateOf("") } @@ -62,12 +60,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( }, showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } }, showVersion = { - withBGApi { - val info = chatModel.controller.apiGetVersion() - if (info != null) { - ModalManager.start.showModal { VersionInfoView(info) } - } - } + ModalManager.start.showModal(cardScreen = true) { VersionInfoView(showSettingsModal, ::doWithAuth) } }, withAuth = ::doWithAuth, ) @@ -84,8 +77,6 @@ fun SettingsLayout( stopped: Boolean, encrypted: Boolean, passphraseSaved: Boolean, - notificationsMode: State, - userDisplayName: String?, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), @@ -98,30 +89,52 @@ fun SettingsLayout( LaunchedEffect(Unit) { hideKeyboard(view) } - val uriHandler = LocalUriHandler.current + val notificationsMode = remember { chatModel.controller.appPrefs.notificationsMode.state } ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.your_settings)) - SectionView(stringResource(MR.strings.settings_section_title_settings)) { - SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) + SectionView { SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }) - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_chat_database)) { + SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.your_privacy), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.help_and_support), showSettingsModal { HelpAndSupportView(it, showModal, showCustomModal) }) DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView() }, stopped) SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped) } - SectionDividerSpaced() + SectionView(stringResource(MR.strings.advanced_settings)) { + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) + if (appPlatform == AppPlatform.ANDROID) { + SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) + } + SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) + AppShutdownItem() + AppVersionItem(showVersion) + } + SectionBottomSpacer() + } +} + +@Composable +fun HelpAndSupportView( + chatModel: ChatModel, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit), +) { + val uriHandler = LocalUriHandler.current + val stopped = chatModel.chatRunning.value == false + val userDisplayName = chatModel.currentUser.value?.displayName ?: "" + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.help_and_support)) + SectionView(stringResource(MR.strings.settings_section_title_help)) { - SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_contact)) { if (!chatModel.desktopNoUserNoRemote) { SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) } @@ -129,27 +142,29 @@ fun SettingsLayout( } SectionDividerSpaced() - SectionView(stringResource(MR.strings.settings_section_title_support)) { + SectionView(stringResource(MR.strings.settings_section_title_support_project)) { if (!BuildConfigCommon.ANDROID_BUNDLE) { ContributeItem(uriHandler) } - RateAppItem(uriHandler) + if (appPlatform.isAndroid) { + RateAppItem(uriHandler) + } StarOnGithubItem(uriHandler) } - SectionDividerSpaced() - - SettingsSectionApp(showSettingsModal, showVersion, withAuth) SectionBottomSpacer() } } @Composable -expect fun SettingsSectionApp( +expect fun AdvancedSettingsAppSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showVersion: () -> Unit, - withAuth: (title: String, desc: String, block: () -> Unit) -> Unit + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) +// Shutdown is only available on Android; on desktop the app is closed via the window. +@Composable +expect fun AppShutdownItem() + @Composable private fun DatabaseItem(encrypted: Boolean, saved: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemView(openDatabaseView) { Row( @@ -160,11 +175,11 @@ expect fun SettingsSectionApp( Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { Icon( painterResource(MR.images.ic_database), - contentDescription = stringResource(MR.strings.database_passphrase_and_export), + contentDescription = stringResource(MR.strings.chat_data), tint = if (encrypted && (appPlatform.isAndroid || !saved)) MaterialTheme.colors.secondary else WarningOrange, ) TextIconSpaced(false) - Text(stringResource(MR.strings.database_passphrase_and_export)) + Text(stringResource(MR.strings.chat_data)) } if (stopped) { Icon( @@ -208,7 +223,7 @@ fun ChatLockItem( } } -@Composable private fun ContributeItem(uriHandler: UriHandler) { +@Composable fun ContributeItem(uriHandler: UriHandler) { SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat#contribute") }) { Icon( painterResource(MR.images.ic_keyboard), @@ -220,7 +235,7 @@ fun ChatLockItem( } } -@Composable private fun RateAppItem(uriHandler: UriHandler) { +@Composable fun RateAppItem(uriHandler: UriHandler) { SectionItemView({ runCatching { uriHandler.openUriCatching("market://details?id=chat.simplex.app") } .onFailure { uriHandler.openUriCatching("https://play.google.com/store/apps/details?id=chat.simplex.app") } @@ -236,7 +251,7 @@ fun ChatLockItem( } } -@Composable private fun StarOnGithubItem(uriHandler: UriHandler) { +@Composable fun StarOnGithubItem(uriHandler: UriHandler) { SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat") }) { Icon( painter = painterResource(MR.images.ic_github), @@ -486,8 +501,6 @@ fun PreviewSettingsLayout() { stopped = false, encrypted = false, passphraseSaved = false, - notificationsMode = remember { mutableStateOf(NotificationsMode.OFF) }, - userDisplayName = "Alice", setPerformLA = { _ -> }, showModal = { {} }, showSettingsModal = { {} }, 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 52addd146b..5070c3c0aa 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 @@ -1,33 +1,55 @@ package chat.simplex.common.views.usersettings +import SectionBottomSpacer +import SectionDividerSpaced +import SectionView +import itemHPadding +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.ChatModel import chat.simplex.common.model.CoreVersionInfo import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.appPlatform -import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.res.MR @Composable -fun VersionInfoView(info: CoreVersionInfo) { - ColumnWithScrollBar( - Modifier.padding(horizontal = DEFAULT_PADDING), - ) { - AppBarTitle(stringResource(MR.strings.app_version_title), withPadding = false) - if (appPlatform.isAndroid) { - Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.ANDROID_VERSION_NAME)) - 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)) +fun VersionInfoView( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, +) { + val versionInfo = remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + versionInfo.value = chatModel.controller.apiGetVersion() + } + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.app_version_title)) + SectionView { + Column(Modifier.padding(horizontal = itemHPadding, vertical = DEFAULT_PADDING_HALF)) { + if (appPlatform.isAndroid) { + Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.ANDROID_VERSION_NAME)) + 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)) + } + versionInfo.value?.let { info -> + 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 + Text(String.format(stringResource(MR.strings.core_simplexmq_version), info.simplexmqVersion, simplexmqCommit)) + } + } } - 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 - Text(String.format(stringResource(MR.strings.core_simplexmq_version), info.simplexmqVersion, simplexmqCommit)) + SectionDividerSpaced() + + AdvancedSettingsAppSection(showSettingsModal, withAuth) + SectionBottomSpacer() } } 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 ee79fc0af0..5bca9402a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1555,6 +1555,13 @@ Files Send delivery receipts to Contact requests from groups + About + Contact + Support the project + Chat data + Help & support + More privacy + Advanced settings Restart Shutdown Developer tools @@ -2681,7 +2688,7 @@ Don\'t enable You can enable later via Settings Delivery receipts are disabled! - You can enable them later via app Privacy & Security settings. + You can enable them later via app Your privacy settings. Error enabling delivery receipts! diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt index 5b4a044df3..174ad63c7a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -1,27 +1,21 @@ package chat.simplex.common.views.usersettings import SectionView -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier +import androidx.compose.runtime.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.AppUpdatesChannel -import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.platform.* 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 @Composable -actual fun SettingsSectionApp( +actual fun AdvancedSettingsAppSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showVersion: () -> Unit, - withAuth: (title: String, desc: String, block: () -> Unit) -> Unit + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) { - SectionView(stringResource(MR.strings.settings_section_title_app)) { + SectionView { SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }) val selectedChannel = remember { appPrefs.appUpdateChannel.state } val values = AppUpdatesChannel.entries.map { it to it.text } @@ -29,6 +23,8 @@ actual fun SettingsSectionApp( appPrefs.appUpdateChannel.set(it) setupUpdateChecker() } - AppVersionItem(showVersion) } } + +@Composable +actual fun AppShutdownItem() {} From 7548fdae3bfd36db2211da287e15d0f29a3fff83 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:46:59 +0000 Subject: [PATCH 06/81] android, desktop: fix chat item long-press menu and ripple shape (#6997) * android, desktop: fix chat item long-press menu and ripple shape clipChatItem clipped the bubble shape with Modifier.clip. Modifier.clip of the bubble GenericShape mis-hit-tests its path on very tall items, so long-press on the lower part of a long message did not reach combinedClickable and the context menu did not open (#6991); on desktop the same clip also left the press ripple rendered as a rectangle. Clip the bubble GenericShape in the draw pass (drawWithCache + clipPath) instead: drawing is clipped identically, the press ripple included, with no effect on hit-test. The RoundRect shape (tail disabled) hit-tests correctly and keeps Modifier.clip. Fixes #6991 * plans: justify chat item long-press and ripple shape fix --------- Co-authored-by: Evgeny Poberezkin --- .../common/views/chat/item/ChatItemView.kt | 25 +++++-- ...2026-05-20-fix-hold-on-long-msg-android.md | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 plans/2026-05-20-fix-hold-on-long-msg-android.md 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 64288d9055..b4abbe70a2 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 @@ -12,8 +12,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.* @@ -1224,12 +1226,25 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal val style = shapeStyle(chatItem, chatItemTail.value, tailVisible, revealed) val cornerRoundness = chatItemRoundness.value.coerceIn(0f, 1f) - val shape = when (style) { - is ShapeStyle.Bubble -> chatItemShape(cornerRoundness, LocalDensity.current, style.tailVisible, chatItem?.chatDir?.sent == true) - is ShapeStyle.RoundRect -> RoundedCornerShape(style.radius * cornerRoundness) + return when (style) { + is ShapeStyle.Bubble -> { + // Modifier.clip of the bubble GenericShape mis-hit-tests its path on very tall + // items, dropping long-press on the lower part of the bubble (issue #6991). Clip + // in the draw pass instead — drawing is clipped identically (the press ripple + // included), with no effect on hit-test. + val shape = chatItemShape(cornerRoundness, LocalDensity.current, style.tailVisible, chatItem?.chatDir?.sent == true) + this.drawWithCache { + val path = Path().apply { + addOutline(shape.createOutline(size, layoutDirection, this@drawWithCache)) + } + onDrawWithContent { + clipPath(path) { this@onDrawWithContent.drawContent() } + } + } + } + // RoundRect hit-tests correctly — no bug here, keep the antialiased Modifier.clip. + is ShapeStyle.RoundRect -> this.clip(RoundedCornerShape(style.radius * cornerRoundness)) } - - return this.clip(shape) } private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ -> diff --git a/plans/2026-05-20-fix-hold-on-long-msg-android.md b/plans/2026-05-20-fix-hold-on-long-msg-android.md new file mode 100644 index 0000000000..f89aa26582 --- /dev/null +++ b/plans/2026-05-20-fix-hold-on-long-msg-android.md @@ -0,0 +1,68 @@ +# Fix chat item long-press menu and ripple shape + +Branch: `nd/fix-hold-on-long-msg-android` · PR [#6997](https://github.com/simplex-chat/simplex-chat/pull/6997) · issue [#6991](https://github.com/simplex-chat/simplex-chat/issues/6991). + +## 1. Problem statement + +Two issues with the chat-item bubble on the multiplatform UI: + +- **Android (#6991):** long-pressing the lower part of a very tall text message did not open the select/copy/reply context menu. Long-press on the top/middle worked. Reproduced with a long multi-line message (~150+ lines — e.g. 5000 random bytes as hex); never reproduced on short messages. Occurs **only with the message tail enabled** (bubble shape); with the tail preference disabled, messages use a plain rounded-rectangle shape and the bug does not reproduce. iOS unaffected. +- **Desktop:** the chat-item press ripple, in some cases, rendered as a rectangle instead of following the rounded bubble shape. + +## 2. Solution summary + +One function — `Modifier.clipChatItem` in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt`. It clipped the chat item with `Modifier.clip(shape)` for every shape style. It now clips the **bubble** (`GenericShape`) in the draw pass with `drawWithCache` + `clipPath`, and keeps `Modifier.clip` for the **`RoundRect`** shape, which is unaffected by the bug (§3). + +```kotlin +return when (style) { + is ShapeStyle.Bubble -> { + val shape = chatItemShape(cornerRoundness, LocalDensity.current, style.tailVisible, chatItem?.chatDir?.sent == true) + this.drawWithCache { + val path = Path().apply { addOutline(shape.createOutline(size, layoutDirection, this@drawWithCache)) } + onDrawWithContent { clipPath(path) { this@onDrawWithContent.drawContent() } } + } + } + is ShapeStyle.RoundRect -> this.clip(RoundedCornerShape(style.radius * cornerRoundness)) +} +``` + +Net diff: 1 file (`ChatItemView.kt`), +20 / −5 — the `clipChatItem` function restructured plus two imports. + +## 3. Root cause + +`Modifier.clip(shape)` is defined in Compose as `graphicsLayer(shape = shape, clip = true)`. A clipping graphics layer restricts **both** drawing **and** pointer hit-test to the shape. + +`clipChatItem` is the first (outermost) modifier on the chat-bubble `Column`, and that same `Column` carries the `combinedClickable` long-press handler. So the layer's hit-test region gates every press on the bubble. + +- **Android:** for a very tall chat item the layer's hit-test region does not cover the bubble's lower portion — a press there is never delivered to `combinedClickable`, so the long-press menu does not open. This is specific to the bubble's `GenericShape` clip: with the tail disabled the item is clipped with a `RoundedCornerShape`, which hit-tests correctly. The exact reason the `GenericShape` clip's hit-test falls short on tall content was not isolated; the fix does not depend on it (see §4). +- **Desktop:** the layer's clip did not always extend to the `combinedClickable` press ripple, so the ripple drew to its own rectangular bounds instead of the bubble shape. + +## 4. The fix + +For the bubble shape, `clipChatItem` clips with a draw modifier instead of a graphics layer. `drawWithCache` builds the shape's `Path` once per size change; `onDrawWithContent { clipPath(path) { drawContent() } }` wraps the whole content draw — bubble background, text, and the press ripple — in a canvas clip. + +A draw modifier affects **only drawing**. It is not a layout or pointer-input node and has no effect on hit-test. Therefore: + +- the bubble and ripple are still clipped to the shape — visually identical to `Modifier.clip`; +- pointer hit-test is no longer clipped — `combinedClickable` receives presses anywhere in the `Column`'s bounds, fixing the Android long-press; +- the canvas `clipPath` clips the ripple reliably, fixing the rectangular desktop ripple. + +The `RoundRect` shape keeps `Modifier.clip`: it hit-tests correctly (no bug) and keeps its antialiased outline clip. Scoping by shape — rather than draw-clipping every shape — leaves every non-bubble chat item (service/event messages, tails-off messages, old Android) byte-for-byte unchanged. + +## 5. Alternatives rejected + +- **Remove `clipChatItem` from the bubble `Column`.** Fixes the Android long-press, but the press ripple loses its shape and renders as a rectangle. Intermediate state during development; replaced. +- **Draw-pass clip for every shape, unconditionally.** Also correct and a hair simpler (no `when`), but it needlessly moves the `RoundRect` shape off `Modifier.clip`'s antialiased outline clip onto a canvas `clipPath` — a behaviour change with no benefit, since `RoundRect` has no bug. Scoping to the bubble shape keeps `RoundRect` unchanged. +- **Keep `Modifier.clip`, move `combinedClickable` off the clipped `Column`.** A larger structural change to the chat-item layout tree; the draw-pass clip fixes both issues without moving anything. + +## 6. Verification + +- **Android** (debug APK): long-press on the lower half of a 150+-line message opens the context menu; top/middle still work; the tap ripple stays bubble-shaped; swipe-to-reply and link tap/long-press are unaffected. +- **Desktop** (Linux AppImage): the chat-item press ripple follows the bubble shape (rounded corners and tail), not a rectangle — confirmed against a build without the fix. +- The bubble draw-pass clip above was verified on those Android and desktop builds; this revision additionally keeps `Modifier.clip` for the `RoundRect` shape, which is the unchanged pre-fix behaviour. + +## 7. Risk and rollback + +- Blast radius: the `Bubble` branch of `clipChatItem`. The `RoundRect` branch is unchanged (`Modifier.clip` as before), so service/event items, tails-off messages and old-Android items are untouched. For the bubble, drawing is clipped identically; the single behavioural change is that pointer hit-test on the bubble is no longer shape-clipped — benign (bubble corners are transparent; a rectangular hit area is a marginally larger touch target). +- iOS is a separate codebase and is untouched. +- Rollback: revert the fix commit on the branch, or drop it before merge. From 7c5406a4e9b8762f9171924ecb77cb379aa93674 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:02:54 +0000 Subject: [PATCH 07/81] desktop: fix drag-and-drop videos attached as files (#7015) onFilesAttached classified URIs by isImage only, so videos fell through to processPickedFile and were attached as generic files. processPickedMedia already handles video correctly; the classifier above it just never reached that branch. Recognise videos as media inline using getFileName. --- .../simplex/common/views/chat/ComposeView.kt | 14 ++++-- plans/2026-05-26-fix-video-drag-and-drop.md | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 plans/2026-05-26-fix-video-drag-and-drop.md 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 d874079238..26824cdd49 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 @@ -288,16 +288,22 @@ expect fun AttachmentSelection( ) fun MutableState.onFilesAttached(uris: List) { - val groups = uris.groupBy { isImage(it) } - val images = groups[true] ?: emptyList() + val groups = uris.groupBy { isImage(it) || isVideoUri(it) } + val media = groups[true] ?: emptyList() val files = groups[false] ?: emptyList() - if (images.isNotEmpty()) { - CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) } + if (media.isNotEmpty()) { + CoroutineScope(Dispatchers.IO).launch { processPickedMedia(media, null) } } else if (files.isNotEmpty()) { processPickedFile(uris.first(), null) } } +private fun isVideoUri(uri: URI): Boolean { + val name = getFileName(uri)?.lowercase() ?: return false + return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") || + name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") +} + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val fileSize = getFileSize(uri) diff --git a/plans/2026-05-26-fix-video-drag-and-drop.md b/plans/2026-05-26-fix-video-drag-and-drop.md new file mode 100644 index 0000000000..16258dea7f --- /dev/null +++ b/plans/2026-05-26-fix-video-drag-and-drop.md @@ -0,0 +1,44 @@ +# Fix desktop drag-and-drop of videos attached as files + +Branch: `nd/fix-video-drag-and-drop` · base: `master`. + +## Problem + +On desktop, dragging a video file into a chat attaches it as a generic file (paperclip + filename) instead of as a video (thumbnail + duration). Dragging an image works. Picking the same video via "Gallery → Video" attaches it correctly — so only the drag-and-drop routing is wrong. + +## Fix + +One file: `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt`. Recognise videos as media in `onFilesAttached`'s classifier. + +```diff + fun MutableState.onFilesAttached(uris: List) { +- val groups = uris.groupBy { isImage(it) } +- val images = groups[true] ?: emptyList() ++ val groups = uris.groupBy { isImage(it) || isVideoUri(it) } ++ val media = groups[true] ?: emptyList() + val files = groups[false] ?: emptyList() +- if (images.isNotEmpty()) { +- CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) } ++ if (media.isNotEmpty()) { ++ CoroutineScope(Dispatchers.IO).launch { processPickedMedia(media, null) } + } else if (files.isNotEmpty()) { + processPickedFile(uris.first(), null) + } + } ++ ++private fun isVideoUri(uri: URI): Boolean { ++ val name = getFileName(uri)?.lowercase() ?: return false ++ return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") || ++ name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") ++} +``` + +Total diff: 1 file, +11 / −5. + +## Cause + +`onFilesAttached` classified URIs by `isImage` only — non-images (including videos) fell through to `processPickedFile`, producing a `FilePreview`. The downstream `processPickedMedia` already handles video correctly (its `else` branch builds `UploadContent.Video`); the classifier above it just never reached that branch. The existing `isVideo` in `Videos.desktop.kt` is `desktopMain`-only and not visible from `ComposeView.kt` in `commonMain` — the structural gap that left the classifier video-blind. The inline `isVideoUri` uses the cross-platform `getFileName`, so the same fix also corrects the paste path (`onFilesPasted` at `ComposeView.kt:1378`). + +## Risk + +One file, no interface change. Image and non-media drops are bit-identical. Video extension list is now duplicated with `Videos.desktop.kt`; adding a new format means updating both — accepted as the cost of a single-file fix. iOS unaffected. Rollback: revert the commit. From f144bf45607e8838c250bae424660658844457f4 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:05:11 +0000 Subject: [PATCH 08/81] android, desktop, ios: fix trailing dot in saved name for files without extension (#7016) --- apps/ios/SimpleXChat/ImageUtils.swift | 2 +- .../kotlin/chat/simplex/common/views/helpers/Utils.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index f93b090517..2d36a7a53a 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -297,7 +297,7 @@ private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String let name = ns.deletingPathExtension let ext = ns.pathExtension let suffix = (n == 0) ? "" : "_\(n)" - let f = "\(name)\(suffix).\(ext)" + let f = ext.isEmpty ? "\(name)\(suffix)" : "\(name)\(suffix).\(ext)" return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f } return tryCombine(fileName, 0) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 424d500085..23c622bc34 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -383,7 +383,7 @@ fun uniqueCombine(fileName: String, dir: File): String { val ext = orig.extension fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" - val f = "$name$suffix.$ext" + val f = if (ext.isEmpty()) "$name$suffix" else "$name$suffix.$ext" return if (File(dir, f).exists()) tryCombine(n + 1) else f } return tryCombine(0) From 503d091ec4c46fecc43da087c76ed1376131c827 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:13:06 +0000 Subject: [PATCH 09/81] android, desktop, ios: show chat name in delete / leave / clear confirmation dialogs (#7021) * android, desktop, ios: show chat name in delete / leave / clear confirmation dialogs The dialogs confirming delete contact, delete/leave group/channel and clear chat now show the chat's name on its own line above the existing warning, so the user can see which chat the destructive action will affect. Pure code change: no new translation strings, no signature changes, no new helpers. The name is read via existing displayName accessors on GroupInfo / Contact / ChatInfo. clearNoteFolderDialog is intentionally unchanged - the notes folder is a single-instance object and its existing warning already identifies it. * android, desktop, ios: also show chat name when deleting pending connection deleteContactConnectionAlert was missed in the original inventory. Same pattern as the other dispatchers - prepend the connection's displayName on its own line above the existing warning - so a user who set a custom name on a pending connection can see which one they are about to delete. * android: use
instead of \n for newline in delete confirmation dialog body On Android, the alert body goes through HtmlCompat.fromHtml which treats the input as HTML and collapses literal \n to a single space - so "Tech Talk\n\nGroup will be deleted..." rendered as "Tech Talk Group will be deleted...". Switch to

, which both HtmlCompat (Android) and the Desktop parser at Utils.desktop.kt:75 correctly render as a newline. * android, desktop: skip HTML parsing for delete confirmation dialog text Add parseHtml: Boolean = true parameter to showAlertDialog and showAlertDialogButtonsColumn; when false, the body text is wrapped as AnnotatedString and routed through the existing AnnotatedString AlertContent overload, bypassing escapedHtmlToAnnotatedString entirely. The 8 dispatchers that embed the user-controlled chat displayName now opt out (parseHtml = false). This means: - displayName is rendered as literal text - no HTML interpretation, so a contact whose alias is "X" or "
Kotlin-only workaround) - removes coupling to escapedHtmlToAnnotatedString in this path * android, desktop: shorten parseHtml comment in AlertManager * remove extra text --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 5 +- .../Views/Chat/Group/GroupChatInfoView.swift | 6 +- .../Views/ChatList/ChatListNavLink.swift | 14 +- .../simplex/common/views/chat/ChatInfoView.kt | 14 +- .../views/chat/group/GroupChatInfoView.kt | 6 +- .../views/chatlist/ChatListNavLinkView.kt | 3 +- .../common/views/helpers/AlertManager.kt | 21 +- .../delete-leave-dialog-with-profile-impl.md | 323 ++++++++++++++++++ plans/delete-leave-dialog-with-profile.md | 249 ++++++++++++++ 9 files changed, 620 insertions(+), 21 deletions(-) create mode 100644 plans/delete-leave-dialog-with-profile-impl.md create mode 100644 plans/delete-leave-dialog-with-profile.md diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index c17d8e23a8..29c87ff7e0 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -577,7 +577,7 @@ struct ChatInfoView: View { private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), - message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), primaryButton: .destructive(Text("Clear")) { Task { await clearChat(chat) @@ -1185,6 +1185,7 @@ private func deleteContactOrConversationDialog( showActionSheet(SomeActionSheet( actionSheet: ActionSheet( title: Text("Delete contact?"), + message: Text(contact.displayName), buttons: [ .destructive(Text("Only delete conversation")) { deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert) @@ -1331,6 +1332,7 @@ private func deleteContactWithoutConversation( showActionSheet(SomeActionSheet( actionSheet: ActionSheet( title: Text("Confirm contact deletion?"), + message: Text(contact.displayName), buttons: [ .destructive(Text("Delete and notify contact")) { deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert) @@ -1355,6 +1357,7 @@ private func deleteNotReadyContact( showActionSheet(SomeActionSheet( actionSheet: ActionSheet( title: Text("Confirm contact deletion?"), + message: Text(contact.displayName), buttons: [ .destructive(Text("Confirm")) { deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0a448a2772..1353f590fa 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -845,7 +845,7 @@ struct GroupChatInfoView: View { let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), - message: deleteGroupAlertMessage(groupInfo), + message: Text(chat.chatInfo.displayName + "\n\n") + deleteGroupAlertMessage(groupInfo), primaryButton: .destructive(Text("Delete")) { Task { do { @@ -867,7 +867,7 @@ struct GroupChatInfoView: View { private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), - message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), primaryButton: .destructive(Text("Clear")) { Task { await clearChat(chat) @@ -889,7 +889,7 @@ struct GroupChatInfoView: View { ) return Alert( title: Text(titleLabel), - message: Text(messageLabel), + message: Text(chat.chatInfo.displayName + "\n\n") + Text(messageLabel), primaryButton: .destructive(Text("Leave")) { Task { await leaveGroup(chat.chatInfo.apiId) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index b4590fc124..76734dcb42 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -568,7 +568,7 @@ struct ChatListNavLink: View { let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), - message: deleteGroupAlertMessage(groupInfo), + message: Text(chat.chatInfo.displayName + "\n\n") + deleteGroupAlertMessage(groupInfo), primaryButton: .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, @@ -600,7 +600,7 @@ struct ChatListNavLink: View { private func clearChatAlert() -> Alert { Alert( title: Text("Clear conversation?"), - message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + message: Text(chat.chatInfo.displayName + "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), primaryButton: .destructive(Text("Clear")) { Task { await clearChat(chat) } }, @@ -630,7 +630,7 @@ struct ChatListNavLink: View { ) return Alert( title: Text(titleLabel), - message: Text(messageLabel), + message: Text(chat.chatInfo.displayName + "\n\n") + Text(messageLabel), primaryButton: .destructive(Text("Leave")) { Task { await leaveGroup(groupInfo.groupId) } }, @@ -701,10 +701,10 @@ func rejectContactRequestAlert(_ contactRequestId: Int64) -> Alert { func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { Alert( title: Text("Delete pending connection?"), - message: - contactConnection.initiated - ? Text("The contact you shared this link with will NOT be able to connect!") - : Text("The connection you accepted will be cancelled!"), + message: Text(contactConnection.displayName + "\n\n") + + (contactConnection.initiated + ? Text("The contact you shared this link with will NOT be able to connect!") + : Text("The connection you accepted will be cancelled!")), primaryButton: .destructive(Text("Delete")) { Task { do { 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 dce1b6ea33..13be8b1d71 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 @@ -248,6 +248,8 @@ fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)?) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), + text = contact.displayName, + parseHtml = false, buttons = { Column { // Only delete conversation @@ -306,7 +308,8 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), - text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + text = "${contact.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + parseHtml = false, buttons = { Column { // Keep conversation toggle @@ -361,7 +364,8 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.confirm_delete_contact_question), - text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + parseHtml = false, buttons = { Column { // Delete and notify contact @@ -417,7 +421,8 @@ private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, c private fun deleteNotReadyContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.confirm_delete_contact_question), - text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + parseHtml = false, buttons = { // Confirm SectionItemView({ @@ -492,7 +497,8 @@ fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDe fun clearChatDialog(chat: Chat, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), - text = generalGetString(MR.strings.clear_chat_warning), + text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.clear_chat_warning)}", + parseHtml = false, confirmText = generalGetString(MR.strings.clear_verb), onConfirm = { controller.clearChat(chat, close) }, destructive = true, 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 3e9b5f6f48..c88e3b2bff 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 @@ -199,7 +199,8 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl } AlertManager.shared.showAlertDialog( title = generalGetString(titleId), - text = generalGetString(messageId), + text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + parseHtml = false, confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withBGApi { @@ -233,7 +234,8 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl MR.strings.you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved AlertManager.shared.showAlertDialog( title = generalGetString(titleId), - text = generalGetString(messageId), + text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + parseHtml = false, confirmText = generalGetString(MR.strings.leave_group_button), onConfirm = { withLongRunningApi(60_000) { 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 d3533bbd02..7dfb52063e 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 @@ -772,10 +772,11 @@ fun rejectContactRequest(rhId: Long?, contactRequestId: Long, chatModel: ChatMod fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_pending_connection__question), - text = generalGetString( + text = "${connection.displayName}\n\n" + generalGetString( if (connection.initiated) MR.strings.contact_you_shared_link_with_wont_be_able_to_connect else MR.strings.connection_you_accepted_will_be_cancelled ), + parseHtml = false, confirmText = generalGetString(MR.strings.delete_verb), onConfirm = { withBGApi { 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 3d670d1c43..1774cf4394 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 @@ -75,6 +75,8 @@ class AlertManager { onDismissRequest: (() -> Unit)? = null, hostDevice: Pair? = null, belowTextContent: @Composable (() -> Unit) = {}, + // When false, [text] is rendered as literal text — use for user-controlled content. + parseHtml: Boolean = true, buttons: @Composable () -> Unit, ) { showAlert { @@ -82,8 +84,14 @@ class AlertManager { onDismissRequest = { onDismissRequest?.invoke(); if (dismissible) hideAlert() }, title = alertTitle(title), buttons = { - AlertContent(text, hostDevice, extraPadding = true, textAlign = textAlign, belowTextContent = belowTextContent) { - buttons() + if (parseHtml) { + AlertContent(text, hostDevice, extraPadding = true, textAlign = textAlign, belowTextContent = belowTextContent) { + buttons() + } + } else { + AlertContent(text?.let { AnnotatedString(it) }, hostDevice, extraPadding = true) { + buttons() + } } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) @@ -122,13 +130,15 @@ class AlertManager { onDismissRequest: (() -> Unit)? = null, destructive: Boolean = false, hostDevice: Pair? = null, + // When false, [text] is rendered as literal text — use for user-controlled content. + parseHtml: Boolean = true, ) { showAlert { AlertDialog( onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = alertTitle(title), buttons = { - AlertContent(text, hostDevice, true) { + val buttonRow: @Composable () -> Unit = { Row( Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceBetween @@ -149,6 +159,11 @@ class AlertManager { }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } } + if (parseHtml) { + AlertContent(text, hostDevice, true, content = buttonRow) + } else { + AlertContent(text?.let { AnnotatedString(it) }, hostDevice, true, content = buttonRow) + } }, shape = RoundedCornerShape(corner = CornerSize(25.dp)) ) diff --git a/plans/delete-leave-dialog-with-profile-impl.md b/plans/delete-leave-dialog-with-profile-impl.md new file mode 100644 index 0000000000..860d555d36 --- /dev/null +++ b/plans/delete-leave-dialog-with-profile-impl.md @@ -0,0 +1,323 @@ +# Implementation plan — chat name on its own line in delete/leave/clear dialogs + +Follows the product spec in +[`delete-leave-dialog-with-profile.md`](./delete-leave-dialog-with-profile.md). + +Pure code change — zero string additions, zero new helpers, zero +signature changes. Each call site edits one argument: the `text =` / +`message:` value gains `"${displayName}\n\n"` prepended to the +existing localized warning (or, where there is no current body, the +chat name becomes the new body). + +One commit per platform. + +## Commit 1 — Kotlin + +**Files touched:** +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt` + — adds `parseHtml: Boolean = true` to `showAlertDialog` and + `showAlertDialogButtonsColumn`. When `false`, the body text is wrapped + as `AnnotatedString` and routed through the existing AnnotatedString + `AlertContent` overload, which does NOT call + `escapedHtmlToAnnotatedString`. Default stays `true` so existing + callers are unaffected. +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt` +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt` + — adds the previously-missed `deleteContactConnectionAlert` + dispatcher to the coverage (pending contact connections). + +Every Kotlin call site that prepends the chat name sets +`parseHtml = false`, so `displayName` is never HTML-interpreted. + +### 1.1 — `deleteGroupDialog` (`GroupChatInfoView.kt:182`) + +```diff + fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo + val titleId = /* unchanged */ + val messageId = /* unchanged */ + AlertManager.shared.showAlertDialog( + title = generalGetString(titleId), +- text = generalGetString(messageId), ++ text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + confirmText = generalGetString(MR.strings.delete_verb), + onConfirm = { /* unchanged */ }, + destructive = true, + ) + } +``` + +### 1.2 — `leaveGroupDialog` (`GroupChatInfoView.kt:222`) + +```diff + fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + val titleId = /* unchanged */ + val messageId = /* unchanged */ + AlertManager.shared.showAlertDialog( + title = generalGetString(titleId), +- text = generalGetString(messageId), ++ text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}", + confirmText = generalGetString(MR.strings.leave_group_button), + onConfirm = { /* unchanged */ }, + destructive = true, + ) + } +``` + +Signature unchanged. No caller updates. `groupInfo.displayName` is +already available on the existing parameter (`ChatModel.kt:2142`). + +### 1.3 — `clearChatDialog` (`ChatInfoView.kt:492`) + +```diff + fun clearChatDialog(chat: Chat, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.clear_chat_question), +- text = generalGetString(MR.strings.clear_chat_warning), ++ text = "${chat.chatInfo.displayName}\n\n${generalGetString(MR.strings.clear_chat_warning)}", + confirmText = generalGetString(MR.strings.clear_verb), + onConfirm = { controller.clearChat(chat, close) }, + destructive = true, + ) + } +``` + +### 1.4 — Contact-delete dispatchers (`ChatInfoView.kt`) + +Four functions. `deleteContactOrConversationDialog` (line 248) has +no existing `text =`, so the chat name becomes the new body. The +other three already have a `text =`, so the name is prepended. + +All four already have `contact: Contact` as a parameter, so +`contact.displayName` is used directly (same value as +`chat.chatInfo.displayName` for a direct chat, shorter expression). + +```diff + // deleteContactOrConversationDialog — line 248 + private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)?) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_contact_question), ++ text = contact.displayName, + buttons = { /* unchanged */ } + ) + } +``` + +```diff + // deleteActiveContactDialog — line 304 + private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)? = null) { + val contactDeleteMode = mutableStateOf(ContactDeleteMode.Full()) + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_contact_question), +- text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), ++ text = "${contact.displayName}\n\n${generalGetString(MR.strings.delete_contact_cannot_undo_warning)}", + buttons = { /* unchanged */ } + ) + } +``` + +Same diff for `deleteContactWithoutConversation` (line 361) and +`deleteNotReadyContact` (line 417) — both use +`delete_contact_cannot_undo_warning`. Neither takes `contact` as +a parameter, so the name is read via `chat.chatInfo.displayName` +(which resolves to `contact.displayName` because these dispatchers +are only reached for `ChatInfo.Direct` chats). Their titles +(`confirm_delete_contact_question`) stay unchanged — the +not-ready / no-conversation paths keep their distinct title. + +## Commit 2 — iOS + +**Files touched:** +- `apps/ios/Shared/Views/ChatList/ChatListNavLink.swift` +- `apps/ios/Shared/Views/Chat/ChatInfoView.swift` +- `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift` + +### 2.1 — `deleteGroupAlert` (two locations) + +`Views/Chat/Group/GroupChatInfoView.swift:835` and +`Views/ChatList/ChatListNavLink.swift:567` get the same diff. +`deleteGroupAlertMessage(_:)` already returns a `Text` containing +the localized warning — concatenate to it. + +```diff + private func deleteGroupAlert() -> Alert { + let label: LocalizedStringKey = /* unchanged */ + return Alert( + title: Text(label), +- message: deleteGroupAlertMessage(groupInfo), ++ message: Text(chat.chatInfo.displayName) + Text(verbatim: "\n\n") + deleteGroupAlertMessage(groupInfo), + primaryButton: .destructive(Text("Delete")) { /* unchanged */ }, + secondaryButton: .cancel() + ) + } +``` + +`Text(chat.chatInfo.displayName)` resolves to `Text(_ content: some StringProtocol)` +(the runtime-string overload — no localization lookup, matches +codebase convention: `ChatView.swift:984`, `ChatInfoToolbar.swift:49`, +`SettingsView.swift:540`). `Text(verbatim: "\n\n")` is the literal +separator, matching the codebase convention that reserves +`verbatim:` for fixed punctuation (`ContextItemView.swift:88` is +the textbook example: `Text(chatLink.displayName) + Text(verbatim: " - ")`). +The third term `Text(messageLabel)` keeps the existing +`LocalizedStringKey` lookup. + +### 2.2 — `leaveGroupAlert` (two locations) + +`Views/Chat/Group/GroupChatInfoView.swift:872` and +`Views/ChatList/ChatListNavLink.swift:622`: + +```diff + private func leaveGroupAlert() -> Alert { + let titleLabel: LocalizedStringKey = /* unchanged */ + let messageLabel: LocalizedStringKey = /* unchanged */ + return Alert( + title: Text(titleLabel), +- message: Text(messageLabel), ++ message: Text(chat.chatInfo.displayName) + Text(verbatim: "\n\n") + Text(messageLabel), + primaryButton: .destructive(Text("Leave")) { /* unchanged */ }, + secondaryButton: .cancel() + ) + } +``` + +### 2.3 — `clearChatAlert` (three locations) + +`Views/Chat/ChatInfoView.swift:577`, +`Views/Chat/Group/GroupChatInfoView.swift:858`, +`Views/ChatList/ChatListNavLink.swift:600`: + +```diff + private func clearChatAlert() -> Alert { + Alert( + title: Text("Clear conversation?"), +- message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), ++ message: Text(chat.chatInfo.displayName) + Text(verbatim: "\n\n") + Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + primaryButton: .destructive(Text("Clear")) { /* unchanged */ }, + secondaryButton: .cancel() + ) + } +``` + +### 2.4 — Contact-delete action sheets + +Three functions in `Views/Chat/ChatInfoView.swift`. None currently +pass `message:` to `ActionSheet`; we add it. `ActionSheet`'s +`message:` is an optional second parameter that SwiftUI already +supports. + +All three functions have `contact: Contact` in scope. Use bare +`Text(contact.displayName)` (resolves to the `StringProtocol` +overload, no localization lookup, matches codebase convention). +Add only the name as `message:` — these ActionSheets had no +message before, so adding any additional warning would be new +behavior beyond the stated goal. + +**`deleteContactOrConversationDialog`** (line 1177): + +```diff + private func deleteContactOrConversationDialog( + _ chat: Chat, _ contact: Contact, _ dismissToChatList: Bool, + _ showAlert: @escaping (SomeAlert) -> Void, + _ showActionSheet: @escaping (SomeActionSheet) -> Void, + _ showSheetContent: @escaping (SomeSheet) -> Void + ) { + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Delete contact?"), ++ message: Text(contact.displayName), + buttons: [ /* unchanged */ ] + ), + id: "deleteContactOrConversationDialog" + )) + } +``` + +**`deleteContactWithoutConversation`** (line 1324): + +```diff + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Confirm contact deletion?"), ++ message: Text(contact.displayName), + buttons: [ /* unchanged */ ] + ), + id: "deleteContactWithoutConversation" + )) +``` + +**`deleteNotReadyContact`** (line 1348) — same: + +```diff + showActionSheet(SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Confirm contact deletion?"), ++ message: Text(contact.displayName), + buttons: [ /* unchanged */ ] + ), + id: "deleteNotReadyContact" + )) +``` + +### 2.5 — `DeleteActiveContactDialog` sheet (line 1282) unchanged + +The secondary multi-option sheet is reached only after the user +confirms "Delete contact" in the previous action sheet — which now +shows the name. The sheet itself remains as-is. + +## Verification + +For each platform, exercise every entry point and confirm the +body reads `` on its own line followed by the existing +warning: + +- Android & Desktop: + - Chat list swipe — direct contact, group, channel, business chat + → delete / clear / leave. (Note folder's clear dialog is + intentionally unchanged — `clearNoteFolderDialog` excluded.) + - Chat info screens — "Delete contact" / "Delete group" / "Delete + channel" / "Clear conversation" / "Leave …" rows. + - Contact list (`ContactListNavView.kt:148`) — "Delete contact" + action shows the name in entry-point dialog and toggle dialog. + - Multi-option contact-delete path: entry dialog (now has a name + body where it had none) → toggle dialog (name above the + warning) → success. +- iOS: + - Same matrix from chat list swipe and chat info screens. + - Action-sheet contact-delete paths show the name as the + `message:` line on iPhone and iPad. + +Edge cases: + +- Long chat name — alert containers wrap automatically; the body + occupies 3+ lines. Confirm with a chat renamed to ~40 characters. +- Special characters (emoji, RTL, double quotes) — render literally + via string interpolation, no format-substitution involved. +- Empty `displayName` — does not occur in practice (`NamedChat` + enforces non-empty via `localAlias.ifEmpty { profile.displayName }`). + +Diff-level checks: + +- `git diff '*strings.xml' '*Localizable.strings'` returns zero + hunks. Pure code change. +- `git diff --stat` shows ~5 files total: two Kotlin dispatcher + files, three iOS view files. +- Cancel/confirm flows behave exactly as before — same API calls, + same model updates, same navigation. + +## Out of scope + +- Profile picture / avatar in dialogs — excluded by product decision. +- Refactoring the iOS duplication between `ChatListNavLink` and + `GroupChatInfoView` / `ChatInfoView` (pre-existing `// TODO` at + `GroupChatInfoView.swift:834`). +- Pre-existing wording divergence between Kotlin's "Clear chat?" + and iOS's "Clear conversation?". Both platforms keep their + titles. +- "Delete invitation" at `ChatListNavLink.swift:236` — has no + confirmation dialog (direct call to `deleteChat(chat)`); nothing + to modify. +- Bolding the chat name. SwiftUI `Text + Text` supports `.bold()` + on the first term, but Jetpack Compose `AlertDialog` text is a + single unstyled string — keeping both unstyled preserves parity. diff --git a/plans/delete-leave-dialog-with-profile.md b/plans/delete-leave-dialog-with-profile.md new file mode 100644 index 0000000000..a05fb66532 --- /dev/null +++ b/plans/delete-leave-dialog-with-profile.md @@ -0,0 +1,249 @@ +# Show chat name in delete / leave / clear confirmation dialogs + +## Goal + +The current delete-contact, delete-group, delete-channel, leave-group, +leave-channel and clear-chat confirmations are generic. From a long +chat list, swiping on a row and triggering one of these actions opens +a dialog whose title is "Delete group?", "Leave channel?", "Clear +conversation?" — with no indication of *which* chat is the target. A +user can easily act on the wrong chat. + +The fix: include the chat's display name in the dialog body, on a line +of its own above the existing warning text. Nothing else changes — +same title, same warning text, same buttons, same colors, same dialog +shape. No profile picture, no layout changes, no new helpers, no new +translation strings. + +We deliberately do NOT reuse the open-chat-link alert layout (centered +profile image + name + open-chat button). That layout is the *invite* +flow's identity; repurposing it for destructive confirmations would +confuse the two flows visually. The minimum change that solves the +"which chat?" problem is putting the name in the body text. + +## Why body, not title; why no new strings + +The title carries the action ("Delete group?", "Leave channel?"). The +body carries the consequences ("Group will be deleted for all +members…"). The chat name belongs with the body — it is the subject +of the consequence, not part of the question. + +Adding the name to the title would require new format-string variants +(`delete_group_named_question` etc.) and per-locale re-translation. +Putting the name on its own line in the body is a pure code change — +the existing translated warnings are concatenated with the chat name +in code: + +``` +Tech Talk + +Group will be deleted for all members - this cannot be undone! +``` + +The display name appears first because the user wants to confirm +*which* chat before reading *what* will happen. The blank line between +the name and the warning makes the name visually distinct. + +## Current state + +### Multiplatform (Kotlin / Android / Desktop) + +All eight dialogs go through `AlertManager.shared.showAlertDialog` or +`showAlertDialogButtonsColumn`: + +- `deleteGroupDialog` — `views/chat/group/GroupChatInfoView.kt:182` +- `leaveGroupDialog` — `views/chat/group/GroupChatInfoView.kt:222` +- `clearChatDialog` — `views/chat/ChatInfoView.kt:492` +- `deleteContactOrConversationDialog` — `views/chat/ChatInfoView.kt:248` +- `deleteActiveContactDialog` — `views/chat/ChatInfoView.kt:304` +- `deleteContactWithoutConversation` — `views/chat/ChatInfoView.kt:361` +- `deleteNotReadyContact` — `views/chat/ChatInfoView.kt:417` +- `deleteContactConnectionAlert` — `views/chatlist/ChatListNavLinkView.kt:772` + (deletes a pending contact connection; takes a `PendingContactConnection` + whose `displayName` reflects any custom name the user set) + +Call sites (chat-info screens, chat-list swipe / overflow, contact +list) funnel through these dispatcher functions. + +### iOS (Swift) + +Two SwiftUI patterns are used: + +- SwiftUI `Alert` with `primaryButton: .destructive` / `.cancel()`: + - `deleteGroupAlert` — `Views/ChatList/ChatListNavLink.swift:567`, + `Views/Chat/Group/GroupChatInfoView.swift:835` + - `leaveGroupAlert` — `Views/ChatList/ChatListNavLink.swift:622`, + `Views/Chat/Group/GroupChatInfoView.swift:872` + - `clearChatAlert` — `Views/ChatList/ChatListNavLink.swift:600`, + `Views/Chat/ChatInfoView.swift:577`, + `Views/Chat/Group/GroupChatInfoView.swift:858` +- SwiftUI `ActionSheet`: + - `deleteContactOrConversationDialog` — + `Views/Chat/ChatInfoView.swift:1177` + - `deleteContactWithoutConversation` — + `Views/Chat/ChatInfoView.swift:1324` + - `deleteNotReadyContact` — `Views/Chat/ChatInfoView.swift:1348` + +`Alert(message:)` accepts `Text`, and `ActionSheet(message:)` (an +existing optional parameter not used today) accepts `Text` too — so +the name can be added by composing the existing message string with +`"\n\n"` and the chat name. No widget changes. + +## Design + +| Dialog | Body today | Body after | +|---|---|---| +| Delete group | `Group will be deleted for all members – this cannot be undone!` | `Tech Talk` + blank line + existing text | +| Delete channel | `Channel will be deleted for all subscribers – this cannot be undone!` | `SimpleX news` + blank line + existing text | +| Leave group | `You will stop receiving messages from this group. …` | `Tech Talk` + blank line + existing text | +| Clear chat | `All messages will be deleted – this cannot be undone! …` | `Alice` + blank line + existing text | +| Delete contact (entry sheet) | *(no body today — title only + buttons)* | `Alice` (becomes the body) | +| Delete contact (active variant) | `Contact will be deleted – this cannot be undone!` | `Alice` + blank line + existing text | +| Confirm contact deletion (not-ready / no-conversation) | `Contact will be deleted – this cannot be undone!` | `Alice` + blank line + existing text | + +Title text is unchanged in every case. Existing titles +(`delete_contact_question`, `confirm_delete_contact_question`, etc.) +keep their semantic distinction — the "Confirm contact deletion?" +title still appears for the not-ready / no-conversation paths. + +### Which name to use: `displayName`, not `chatViewName` + +The chat list row labels chats with `cInfo.chatViewName` +(`ChatPreviewView.kt:87`), defined as: + +```kotlin +val chatViewName: String + get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } +``` + +The dialog uses `chatInfo.displayName` (and `groupInfo.displayName` +for the leave dialog). For most chats these are identical: + +- If `localAlias` is set, both resolve to the alias. +- If `displayName == fullName` (or `fullName` is empty), both resolve + to `displayName`. + +For a contact with distinct display name and full name (no alias), +the row would show `alice / Alice Smith` while the dialog shows +`alice`. Acceptable: `displayName` is the recognizable identifier, +shorter, and the dialog format (single line above the warning) +benefits from concision. Two-part identifiers in the dialog would +crowd the layout. + +### `clearNoteFolderDialog` is excluded + +The local notes folder is a single-instance object — there is only +one per user — and its existing warning text already names it +unambiguously. Adding the display name on its own line would be +pure redundancy. Skipped. + +## Changes + +### Multiplatform (Kotlin) + +Each dispatcher function changes one argument: the `text =` parameter +passed to `AlertManager.shared.showAlertDialog` / +`showAlertDialogButtonsColumn`. The new value is the chat name + two +newlines + the existing message text: + +```kotlin +text = "${chatInfo.displayName}\n\n${generalGetString(messageId)}", +parseHtml = false, +``` + +`parseHtml = false` is a new boolean parameter added to both alert +helpers. It bypasses `escapedHtmlToAnnotatedString` so the +user-controlled `displayName` is rendered as literal text, never +interpreted as HTML markup (``, ``, `&`, etc.). The default +remains `true`; only our delete-confirmation dispatchers opt out. + +For `leaveGroupDialog` the source is `groupInfo.displayName` (the +function already takes `groupInfo` — no signature change needed, +no caller updates needed). + +For `deleteGroupDialog`, also `groupInfo.displayName`, for consistency +with `leaveGroupDialog` (both have `groupInfo` already in scope). + +For `deleteContactOrConversationDialog`, which has no `text =` +parameter today, add `text = chatInfo.displayName` (no concatenation +needed — the dialog had no body text before). + +### iOS + +Each of the eight call sites changes one argument: the `message:` +parameter passed to `Alert(…)` or `ActionSheet(…)`. The new value +composes the chat name with the existing localized message string: + +```swift +message: Text("\(chat.chatInfo.displayName)\n\n\(existingMessage)"), +``` + +For the three `ActionSheet` sites that have no `message:` today, add +`message: Text(chat.chatInfo.displayName)`. + +## Out of scope + +- Profile picture / avatar in any of these dialogs — excluded by + decision: the open-chat-link alert owns that layout, and reusing + it for destructive confirmations conflates two semantically + different flows. +- The pre-existing wording divergence between Kotlin's + `clear_chat_question` ("Clear chat?") and iOS's "Clear + conversation?". Both platforms keep their existing titles. +- Refactoring the iOS duplication between `ChatListNavLink` and + `GroupChatInfoView` / `ChatInfoView` (pre-existing `// TODO reuse + this and clearChatAlert with ChatInfoView` at + `GroupChatInfoView.swift:834`). +- "Delete invitation" at `ChatListNavLink.swift:236` — goes through + `deleteChat(chat)` directly with no confirmation dialog. No dialog + to modify. +- Bolding the chat name on its own line. SwiftUI `Text` concatenation + supports `.bold()`; Jetpack Compose `AlertDialog` text is a single + string. Keep both platforms unstyled for parity. + +## Verification + +Per platform, exercise every entry point and confirm the dialog body +reads `` on its own line followed by a blank line followed +by the existing warning: + +- Android & Desktop: + - Chat list swipe — direct contact, group, channel, business chat + → delete / clear / leave actions. (Note folder's clear dialog + is intentionally unchanged.) + - Chat info screens — "Delete contact" / "Delete group" / "Delete + channel" / "Clear conversation" / "Leave …" rows. + - Contact list (`ContactListNavView.kt:148`) — "Delete contact" + action. + - The multi-option contact-delete path: entry dialog (now has a + name body where it had none) → toggle dialog (name above the + warning) → success. +- iOS: + - Same matrix from chat list swipe and chat info screens. + - Action-sheet contact-delete paths show the name as the + `message:` line. + +Edge cases: + +- Long chat name (40+ chars) — alert containers wrap automatically; + body now occupies 3+ lines (name on 2, blank line, warning on 1+). + Confirm via a chat renamed to a long string. +- Special characters in name (emoji, RTL text, double quotes) — + render literally because the substitution is string concatenation, + not format expansion. A contact named `Bob "the builder"` displays + as `Bob "the builder"` on its own line. No quoting/escaping issue. +- Empty `displayName` would render an empty first line above the + warning. In practice `displayName` is non-empty (the `NamedChat` + interface enforces it via `localAlias.ifEmpty { profile.displayName }`); + no defensive trimming added. + +Diff-level checks: + +- `git diff strings.xml` and `git diff '*Localizable.strings'` show + zero hunks. The change is pure code. +- `git diff --stat` shows each platform touched in 2–4 files: + the dispatcher file(s) on Kotlin (`ChatInfoView.kt`, + `GroupChatInfoView.kt`), and the SwiftUI views holding the + alert/sheet builders on iOS. +- Behavior is unchanged. Cancel returns to the prior screen; + confirm performs the same destructive API call as before. From dd150e2d703e30e52496891ca6715712ecd01db8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:47:39 +0000 Subject: [PATCH 10/81] core: limit xftp file description (#7060) --- src/Simplex/Chat/Store/Files.hs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 5289a3b304..6cb8e39bbc 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -79,6 +79,7 @@ import Data.Functor ((<&>)) import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) +import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality @@ -422,7 +423,7 @@ createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize createRcvFD_ :: DB.Connection -> UserId -> UTCTime -> FileDescr -> ExceptT StoreError IO RcvFileDescr createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, fileDescrComplete} = do - when (fileDescrPartNo /= 0) $ throwError SERcvFileInvalidDescrPart + when (fileDescrPartNo /= 0 || not (rcvFileDescrWithinLimits fileDescrPartNo fileDescrText)) $ throwError SERcvFileInvalidDescrPart fileDescrId <- liftIO $ do DB.execute db @@ -450,8 +451,8 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD fileDescrPartNo = rfdPNo, fileDescrComplete = rfdComplete } -> do - when (fileDescrPartNo /= rfdPNo + 1 || rfdComplete) $ throwError SERcvFileInvalidDescrPart let fileDescrText' = rfdText <> fileDescrText + when (fileDescrPartNo /= rfdPNo + 1 || rfdComplete || not (rcvFileDescrWithinLimits fileDescrPartNo fileDescrText')) $ throwError SERcvFileInvalidDescrPart liftIO $ DB.execute db @@ -463,6 +464,23 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD (fileDescrText', fileDescrPartNo, BI fileDescrComplete, fileDescrId) pure RcvFileDescr {fileDescrId, fileDescrText = fileDescrText', fileDescrPartNo, fileDescrComplete} +-- Upper bounds sized above the largest legitimate received description; derived from simplexmq's +-- chunk tiers and redundancy, so a change there must revisit them. +-- ~1280 chunks max = maxFileSizeHard (5gb) / largest chunk tier (4mb). +-- ~150 chars per chunk in the description YAML = replicaId 24 + Ed25519 key 64 + SHA-256 digest 44 + chunkNo/colons. +-- Total ~0.18 MB at 1 replica/chunk (~0.42 MB at 3x), under the 1mb text and 1024 part caps. +maxRcvFileDescrParts :: Int +maxRcvFileDescrParts = 1024 + +maxRcvFileDescrTextLength :: Int +maxRcvFileDescrTextLength = 1024 * 1024 + +rcvFileDescrWithinLimits :: Int -> Text -> Bool +rcvFileDescrWithinLimits partNo descrText = + partNo >= 0 + && partNo <= maxRcvFileDescrParts + && T.length descrText <= maxRcvFileDescrTextLength + getRcvFileDescrByRcvFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr getRcvFileDescrByRcvFileId db fileId = do liftIO (getRcvFileDescrByRcvFileId_ db fileId) >>= \case From 931881c86039764a12425ee7b5602c68527bb2a7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:03:51 +0000 Subject: [PATCH 11/81] core: validate user chat ownership for chat tag and TTL APIs (#7063) --- src/Simplex/Chat/Library/Commands.hs | 31 ++++++++++++++++++---------- tests/ChatTests/Direct.hs | 20 ++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 0671b2e091..b72eae7c72 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -652,12 +652,14 @@ processChatCommand cxt nm = \case _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user APISetChatTags (ChatRef cType chatId scope) tagIds -> withUser $ \user -> case cType of - CTDirect -> withFastStore' $ \db -> do - updateDirectChatTags db chatId (maybe [] L.toList tagIds) - CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId - CTGroup | isNothing scope -> withFastStore' $ \db -> do - updateGroupChatTags db chatId (maybe [] L.toList tagIds) - CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId + CTDirect -> withFastStore $ \db -> do + Contact {contactId} <- getContact db cxt user chatId + liftIO $ updateDirectChatTags db contactId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getDirectChatTags db contactId) + CTGroup | isNothing scope -> withFastStore $ \db -> do + GroupInfo {groupId} <- getGroupInfo db cxt user chatId + liftIO $ updateGroupChatTags db groupId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getGroupChatTags db groupId) _ -> throwCmdError "not supported" APIDeleteChatTag tagId -> withUser $ \user -> do withFastStore' $ \db -> deleteChatTag db user tagId @@ -1692,8 +1694,11 @@ processChatCommand cxt nm = \case CRServerOperatorConditions <$> getServerOperators db APISetChatTTL userId (ChatRef cType chatId scope) newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do - (oldTTL_, globalTTL, ttlCount) <- withStore' $ \db -> - (,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user + (oldTTL_, globalTTL, ttlCount) <- withStore $ \db -> do + oldTTL <- getSetChatTTL db user + globalTTL <- liftIO $ getChatItemTTL db user + ttlCount <- liftIO $ getChatTTLCount db user + pure (oldTTL, globalTTL, ttlCount) let newTTL = fromMaybe globalTTL newTTL_ oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do @@ -1702,9 +1707,13 @@ processChatCommand cxt nm = \case lift $ setChatItemsExpiration user globalTTL ttlCount ok user where - getSetChatTTL db = case cType of - CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ - CTGroup | isNothing scope -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + getSetChatTTL db currentUser = case cType of + CTDirect -> do + Contact {contactId} <- getContact db cxt currentUser chatId + liftIO $ getDirectChatTTL db contactId <* setDirectChatTTL db contactId newTTL_ + CTGroup | isNothing scope -> do + GroupInfo {groupId} <- getGroupInfo db cxt currentUser chatId + liftIO $ getGroupChatTTL db groupId <* setGroupChatTTL db groupId newTTL_ _ -> pure Nothing expireChat user globalTTL = do currentTs <- liftIO getCurrentTime diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 740e757ed8..7acfae1a95 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -122,6 +122,7 @@ chatDirectTests = do it "create user with same servers" testCreateUserSameServers it "delete user" testDeleteUser it "delete user with chat tags" testDeleteUserChatTags + it "rejects raw chat TTL updates for another user's chat" testRejectCrossUserChatTTL it "users have different chat item TTL configuration, chat items expire" testUsersDifferentCIExpirationTTL it "chat items expire after restart for all users according to per user configuration" testUsersRestartCIExpiration it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser @@ -2096,6 +2097,25 @@ testDeleteUserChatTags = alice ##> "/users" alice <## "alisa (active)" +testRejectCrossUserChatTTL :: HasCallStack => TestParams -> IO () +testRejectCrossUserChatTTL = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice #$> ("/_ttl 1 @2 2", id, "ok") + alice #$> ("/ttl @bob", id, "old messages are set to be deleted after: 2 second(s)") + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + alice ##> "/_ttl 2 @2 9" + alice <##. "chat db error:" + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl @bob", id, "old messages are set to be deleted after: 2 second(s)") + testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () testUsersDifferentCIExpirationTTL ps = do withNewTestChat ps "bob" bobProfile $ \bob -> do From b9d1f0c0a3c37fa17894d7dcd2e7c48a9ffd8ff3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:58:17 +0000 Subject: [PATCH 12/81] core: fix delivery batching (#7065) --- src/Simplex/Chat/Library/Subscriber.hs | 6 +-- src/Simplex/Chat/Messages/Batch.hs | 14 +++---- tests/MessageBatching.hs | 54 +++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2e07282036..ba1f7f7d1f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3635,13 +3635,13 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks + let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" - lift . void $ getDeliveryJobWorker True deliveryKey + when (isJust body_) . lift . void $ getDeliveryJobWorker True deliveryKey -- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion DJRelayRemoved | workerScope /= DWSGroup -> diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index ed65bd4af7..81861aad74 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -24,7 +24,6 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B import Data.Char (ord) import Data.Function (on) -import Data.Foldable (foldr') import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L @@ -79,15 +78,15 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) let encoded = encodeBatch mode bodies in Right (MsgBatch encoded msgs) : batches --- | Batches delivery tasks into (batch, accepted, large). +-- | Batches delivery tasks into (batch if any task was accepted, accepted, large). -- Always uses binary batch format for relay groups. -batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (Maybe ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList where addToBatch :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> MessageDeliveryTask -> ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) addToBatch (msgBodies, accepted, large, len, n) task - -- too large: skip, record in large - | msgLen > maxLen = (msgBodies, accepted, task : large, len, n) + -- element can't fit even a singleton batch (4-byte binary-batch framing) + | msgLen + 4 > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) @@ -98,10 +97,11 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} verifiedMsg msgLen = B.length msgBody len' = len + msgLen - toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) + toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (Maybe ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) toResult (msgBodies, accepted, large, _, _) = let encoded = encodeBinaryBatch (reverse msgBodies) - in (encoded, reverse accepted, reverse large) + body = if null accepted then Nothing else Just encoded + in (body, reverse accepted, reverse large) -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 05322a0834..00cbbd757b 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -12,14 +12,31 @@ import qualified Data.ByteString as B import Data.ByteString.Internal (c2w) import Data.Either (partitionEithers) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty (..)) import Data.String (IsString (..)) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) +import Simplex.Chat.Delivery + ( DeliveryJobScope (DJSGroup, jobSpec), + DeliveryJobSpec (DJDeliveryJob, includePending), + MessageDeliveryTask (MessageDeliveryTask, brokerTs, fwdSender, jobScope, senderGMId, taskId, verifiedMsg), + deliveryTaskId, + ) import Simplex.Chat.Messages.Batch import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Messages (SndMessage (..)) -import Simplex.Chat.Protocol (maxEncodedMsgLength) -import Simplex.Chat.Types (SharedMsgId (..)) +import Simplex.Chat.Protocol + ( ChatMessage (ChatMessage), + ChatMsgEvent (XMsgNew), + FwdSender (FwdChannel), + GrpMsgForward (GrpMsgForward), + MsgContent (MCText), + VerifiedMsg (VMUnsigned), + maxEncodedMsgLength, + mcSimple, + ) +import Simplex.Chat.Types (SharedMsgId (..), chatInitialVRange) import Simplex.Messaging.Encoding (Large (..), smpEncodeList) import Test.Hspec @@ -28,6 +45,8 @@ batchingTests = describe "message batching tests" $ do testBatchingCorrectness testBinaryBatchingCorrectness it "image x.msg.new and x.msg.file.descr should fit into single batch" testImageFitsSingleBatch + it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge + it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow instance IsString SndMessage where fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s', signedMsg_ = Nothing} @@ -131,6 +150,37 @@ testImageFitsSingleBatch = do runBatcherTest' BMJson maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] +testRelayBatchAllLarge :: IO () +testRelayBatchAllLarge = do + let task1 = deliveryTask 1 "one" + task2 = deliveryTask 2 "two" + (body_, accepted, large) = batchDeliveryTasks1 chatInitialVRange 1 (task1 :| [task2]) + body_ `shouldBe` Nothing + map deliveryTaskId accepted `shouldBe` [] + map deliveryTaskId large `shouldBe` [1, 2] + +deliveryTask :: Int64 -> T.Text -> MessageDeliveryTask +deliveryTask taskId text = + MessageDeliveryTask + { taskId, + jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, + senderGMId = 1, + fwdSender = FwdChannel, + brokerTs = systemToUTCTime $ MkSystemTime 0 0, + verifiedMsg = + VMUnsigned + (ChatMessage chatInitialVRange Nothing $ XMsgNew $ mcSimple $ MCText text) + } + +testRelayBatchSingletonOverflow :: IO () +testRelayBatchSingletonOverflow = do + let task = deliveryTask 1 "overflow" + elemLen = B.length $ encodeFwdElement (GrpMsgForward (fwdSender task) (brokerTs task)) (verifiedMsg task) + (body_, accepted, large) = batchDeliveryTasks1 chatInitialVRange (elemLen + 2) (task :| []) + body_ `shouldBe` Nothing + map deliveryTaskId accepted `shouldBe` [] + map deliveryTaskId large `shouldBe` [1] + runBatcherTest :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec runBatcherTest mode maxLen msgs expectedErrors expectedBatches = it From ad23da63d0f3c5388c08a5d7db8f4e3ddf2c6915 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 15 Jun 2026 22:25:08 +0100 Subject: [PATCH 13/81] core: supporter badges using anonymous BBS credentials (#7040) * core: supporter badges using anonymous BBS credentials * badges in profiles * badge in profiles * process badges * update simplexmq * update simplexmq * change types * fix migration * migration * update simplexmq * fix bot API, schema * fix postgresql build * refactor * postgresql schema * correctly set badges in all cases * badges ffi * plan, bot types * FFI * FFI: export badge symbols * add extra field * refactor badge types to GADT * configurable badge key * add badge to profile, test * ui: badge images * generate badge key and sign badge * badge sign in CLI * fix commands, ui * rename badges * Binary * image size, migration * update badge images, add public key * send badges in more cases * update UI, tests * bot types, schema * postgres schema * tone down badges * revert formula * refactor badges * smaller badges * badge position * better badge position * simpler * position * move position * update simplexmq * show badge after name * badge layout * fix badge * debug badge height * shift badge * fix badge in member name * bigger badge * badge layout * differentiate badge colors * more avatars for the user's profiles * refactor * remove color filter * alerts * multiple keys, old expired * use new BBS api * update badge keys, bot api * presentation header * simplify * parser * update iOS images * update public keys * query plans * update simplexmq * refactor badge types * simplexmq * bot api types * update simplexmq - commoncrypto flag * update simplexmq * pass commoncrypto flag to simplexmq in nix iOS build * ios ui * update core library, fixes * badge layout * badge size * badge gap * remove extensions * simplify * share badge in more events, reverify badge if verification failed * larger files with badges * allow sending larger files * simpler * update simplexmq * better decoder for badge keys * update simplexmq --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum --- .../badge-investor.imageset/Contents.json | 21 + .../badge-investor.svg | 12 + .../badge-legend.imageset/Contents.json | 21 + .../badge-legend.imageset/badge-legend.svg | 12 + .../badge-supporter.imageset/Contents.json | 21 + .../badge-supporter.svg | 12 + .../Shared/Views/Chat/ChatInfoToolbar.swift | 2 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 24 +- .../Views/Chat/ChatItem/CIFileView.swift | 11 +- .../Views/Chat/ChatItem/CIImageView.swift | 15 +- .../Views/Chat/ChatItem/CIVideoView.swift | 18 +- .../Views/Chat/ChatItem/FramedItemView.swift | 6 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 34 +- apps/ios/Shared/Views/Chat/ChatView.swift | 12 +- .../Chat/ComposeMessage/ComposeView.swift | 4 +- .../Chat/Group/AddGroupMembersView.swift | 9 +- .../Views/Chat/Group/ChannelMembersView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 2 +- .../Chat/Group/GroupMemberInfoView.swift | 21 +- .../Chat/Group/MemberSupportChatToolbar.swift | 2 +- .../Views/Chat/Group/MemberSupportView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 10 +- .../Views/ChatList/ContactRequestView.swift | 16 +- .../Shared/Views/ChatList/UserPicker.swift | 3 +- .../Views/Contacts/ContactListNavLink.swift | 10 +- apps/ios/Shared/Views/Helpers/NameBadge.swift | 174 ++++++++ .../ios/Shared/Views/Helpers/ShareSheet.swift | 14 +- .../Shared/Views/NewChat/NewChatView.swift | 10 +- .../Views/UserSettings/SettingsView.swift | 4 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 +- apps/ios/SimpleXChat/ChatTypes.swift | 87 +++- apps/ios/SimpleXChat/FileUtils.swift | 27 +- .../views/chatlist/UserPicker.android.kt | 3 +- .../chat/simplex/common/model/ChatModel.kt | 96 +++- .../simplex/common/views/chat/ChatInfoView.kt | 27 +- .../common/views/chat/ChatItemInfoView.kt | 18 +- .../simplex/common/views/chat/ChatView.kt | 21 +- .../simplex/common/views/chat/ComposeView.kt | 17 +- .../views/chat/group/AddGroupMembersView.kt | 5 +- .../views/chat/group/ChannelMembersView.kt | 3 +- .../views/chat/group/GroupChatInfoView.kt | 6 +- .../views/chat/group/GroupMemberInfoView.kt | 28 +- .../views/chat/group/MemberSupportChatView.kt | 4 +- .../views/chat/group/MemberSupportView.kt | 4 +- .../common/views/chat/item/CIFileView.kt | 11 +- .../common/views/chat/item/CIImageView.kt | 12 +- .../common/views/chat/item/CIVideoView.kt | 18 +- .../common/views/chat/item/ChatItemView.kt | 2 +- .../common/views/chat/item/FramedItemView.kt | 6 +- .../common/views/chatlist/ChatPreviewView.kt | 16 +- .../views/chatlist/ContactRequestView.kt | 3 +- .../views/chatlist/ShareListNavLinkView.kt | 4 +- .../common/views/chatlist/UserPicker.kt | 9 +- .../views/contacts/ContactPreviewView.kt | 6 +- .../common/views/helpers/AlertManager.kt | 15 +- .../common/views/helpers/ChatInfoImage.kt | 148 +++++++ .../simplex/common/views/helpers/Utils.kt | 25 +- .../common/views/newchat/ConnectPlan.kt | 3 + .../common/views/newchat/NewChatView.kt | 8 +- .../common/views/usersettings/SettingsView.kt | 5 +- .../commonMain/resources/MR/base/strings.xml | 8 + .../resources/MR/images/badge_investor.svg | 12 + .../resources/MR/images/badge_legend.svg | 12 + .../resources/MR/images/badge_supporter.svg | 12 + .../views/chatlist/UserPicker.desktop.kt | 21 +- apps/simplex-chat/Main.hs | 8 +- .../src/Directory/Store.hs | 61 +-- bots/api/TYPES.md | 62 ++- bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Types.hs | 11 + bots/src/API/TypeInfo.hs | 4 + cabal.project | 2 +- flake.nix | 8 + libsimplex.dll.def | 2 + .../types/typescript/src/types.ts | 37 +- .../src/simplex_chat/types/_types.py | 24 +- plans/2026-06-01-supporter-badges-v1.md | 80 ++++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 6 + src/Simplex/Chat.hs | 12 + src/Simplex/Chat/Badges.hs | 414 ++++++++++++++++++ src/Simplex/Chat/Badges/CLI.hs | 87 ++++ src/Simplex/Chat/Controller.hs | 7 +- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 113 +++-- src/Simplex/Chat/Library/Internal.hs | 55 ++- src/Simplex/Chat/Library/Subscriber.hs | 61 +-- src/Simplex/Chat/Mobile.hs | 5 + src/Simplex/Chat/Mobile/Badges.hs | 74 ++++ src/Simplex/Chat/ProfileGenerator.hs | 2 +- src/Simplex/Chat/Protocol.hs | 6 +- src/Simplex/Chat/Store/Connections.hs | 28 +- src/Simplex/Chat/Store/ContactRequest.hs | 43 +- src/Simplex/Chat/Store/Delivery.hs | 3 +- src/Simplex/Chat/Store/Direct.hs | 116 ++--- src/Simplex/Chat/Store/Groups.hs | 212 +++++---- src/Simplex/Chat/Store/Messages.hs | 48 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260516_supporter_badges.hs | 35 ++ .../Store/Postgres/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/Profiles.hs | 95 ++-- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260516_supporter_badges.hs | 34 ++ .../SQLite/Migrations/chat_query_plans.txt | 131 ++++-- .../Store/SQLite/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/Shared.hs | 80 ++-- src/Simplex/Chat/Types.hs | 56 ++- src/Simplex/Chat/View.hs | 80 +++- tests/BadgeTests.hs | 142 ++++++ tests/Bots/BroadcastTests.hs | 2 +- tests/Bots/DirectoryTests.hs | 2 +- tests/ChatTests/Profiles.hs | 235 +++++++++- tests/ChatTests/Utils.hs | 2 +- tests/MobileTests.hs | 23 + tests/ProtocolTests.hs | 4 +- tests/Test.hs | 2 + 116 files changed, 3121 insertions(+), 654 deletions(-) create mode 100644 apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg create mode 100644 apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg create mode 100644 apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg create mode 100644 apps/ios/Shared/Views/Helpers/NameBadge.swift create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg create mode 100644 plans/2026-06-01-supporter-badges-v1.md create mode 100644 src/Simplex/Chat/Badges.hs create mode 100644 src/Simplex/Chat/Badges/CLI.hs create mode 100644 src/Simplex/Chat/Mobile/Badges.hs create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs create mode 100644 tests/BadgeTests.hs diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json new file mode 100644 index 0000000000..9d066d386e --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-investor.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-investor.imageset/badge-investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json new file mode 100644 index 0000000000..b8b9a000d6 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-legend.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-legend.imageset/badge-legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json new file mode 100644 index 0000000000..443575f1c7 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "badge-supporter.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/badge-supporter.imageset/badge-supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index e158b9374f..00c8d7070b 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -47,7 +47,7 @@ struct ChatInfoToolbar: View { } .padding(.trailing, 4) let t = Text(cInfo.displayName).font(.headline) - (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) + NameWithBadge((cInfo.contact?.verified == true ? contactVerifiedShield + t : t), cInfo.nameBadge, .headline) .lineLimit(1) .if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in VStack(spacing: 0) { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index c17d8e23a8..b21def7944 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -374,25 +374,17 @@ struct ChatInfoView: View { // show actual display name, alias can be edited in this view let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if contact.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = cInfo.nameBadge + // the shield is smaller (.title2) than the name (.largeTitle), so on the shared baseline it + // sits low; raise it by half the cap-height difference to center it with the capitals + let shieldRaise = (UIFont.preferredFont(forTextStyle: .largeTitle).capHeight - UIFont.preferredFont(forTextStyle: .title2).capHeight) / 2 + let nameText = contact.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2).baselineOffset(shieldRaise) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(cInfo.fullName) .font(.title2) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 639de1dbc9..75a5baafee 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -16,6 +16,7 @@ struct CIFileView: View { @EnvironmentObject var theme: AppTheme let file: CIFile? let edited: Bool + let senderProfile: LocalProfile? var smallViewSize: CGFloat? var body: some View { @@ -85,7 +86,7 @@ struct CIFileView: View { if let file = file { switch (file.fileStatus) { case .rcvInvitation, .rcvAborted: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { @@ -93,7 +94,7 @@ struct CIFileView: View { } } } else { - let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary) + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) AlertManager.shared.showAlertMsg( title: "Large file!", message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." @@ -165,7 +166,7 @@ struct CIFileView: View { case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .rcvInvitation: - if fileSizeValid(file) { + if fileSizeValid(file, senderProfile) { fileIcon("arrow.down.doc.fill", color: theme.colors.primary) } else { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) @@ -227,9 +228,9 @@ struct CIFileView: View { } } -func fileSizeValid(_ file: CIFile?) -> Bool { +func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool { if let file = file { - return file.fileSize <= getMaxFileSize(file.fileProtocol) + return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) } return false } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b56f1f9f2a..972e9c4ec6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -14,6 +14,7 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem + let senderProfile: LocalProfile? var scrollToItem: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat @@ -51,10 +52,18 @@ struct CIImageView: View { if let file = file { switch file.fileStatus { case .rcvInvitation, .rcvAborted: - Task { - if let user = m.currentUser { - await receiveFile(user: user, fileId: file.fileId) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user: user, fileId: file.fileId) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } case .rcvAccepted: switch file.fileProtocol { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index e1172dab92..912fde4043 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -16,6 +16,7 @@ import Combine struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem + private let senderProfile: LocalProfile? private let preview: UIImage? @State private var duration: Int @State private var progress: Int = 0 @@ -35,8 +36,9 @@ struct CIVideoView: View { private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 } @State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 - init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { + init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding) { self.chatItem = chatItem + self.senderProfile = senderProfile self.preview = preview self._duration = State(initialValue: duration) self.maxWidth = maxWidth @@ -421,10 +423,18 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { - Task { - if let user = m.currentUser { - await receiveFile(user, file.fileId, false, false) + if fileSizeValid(file, senderProfile) { + Task { + if let user = m.currentUser { + await receiveFile(user, file.fileId, false, false) + } } + } else { + let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary) + AlertManager.shared.showAlertMsg( + title: "Large file!", + message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))." + ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d09289c1d5..372c7df8a3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -127,7 +127,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -142,7 +142,7 @@ struct FramedItemView: View { ciMsgContentView(chatItem) } case let .video(text, _, duration): - CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -349,7 +349,7 @@ struct FramedItemView: View { } @ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View { - CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited) + CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo)) .overlay(DetermineWidth()) if text != "" || ci.meta.isLive { ciMsgContentView (chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 3858d15252..bd0e549d38 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -387,23 +387,31 @@ struct ChatItemInfoView: View { Text("you") .italic() .foregroundColor(theme.colors.onBackground) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir { VStack(alignment: .leading) { - Text(groupMember.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.secondary) - .lineLimit(1) + NameWithBadge( + Text(groupMember.chatViewName).foregroundColor(theme.colors.onBackground), + groupMember.nameBadge + ) + .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } else { - Text(forwardedFromItem.chatInfo.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground), + forwardedFromItem.chatInfo.nameBadge + ) + .lineLimit(1) } } } @@ -451,7 +459,7 @@ struct ChatItemInfoView: View { HStack{ MemberProfileImage(member, size: 30) .padding(.trailing, 2) - Text(member.chatViewName) + NameWithBadge(Text(member.chatViewName), member.nameBadge) .lineLimit(1) Spacer() if sentViaProxy == true { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 66148034df..efe26fdf89 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -981,8 +981,8 @@ struct ChatView: View { let v = VStack(spacing: 8) { ChatInfoImage(chat: chat, size: alertProfileImageSize) - Text(chat.chatInfo.displayName) - .font(.title3) + let badge = chat.chatInfo.nameBadge + NameWithBadge(Text(chat.chatInfo.displayName).font(.title3), badge, .title3) { if let badge { showBadgeInfoAlert(chat.chatInfo.displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -2003,7 +2003,7 @@ struct ChatView: View { Group { if #available(iOS 16.0, *) { MemberLayout(spacing: 16, msgWidth: msgWidth) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2012,7 +2012,7 @@ struct ChatView: View { } } else { HStack(spacing: 16) { - Text(name) + NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1) .lineLimit(1) Text(role) .fontWeight(.semibold) @@ -2026,7 +2026,7 @@ struct ChatView: View { alignment: chatItem.chatDir.sent ? .trailing : .leading ) } else { - Text(memberNames(member, prevMember, memCount)) + NameWithBadge(Text(memberNames(member, prevMember, memCount)), memCount == 1 ? member.nameBadge : nil, .caption1) .lineLimit(2) } } @@ -2311,7 +2311,7 @@ struct ChatView: View { } else { saveButton(file: fileSource) } - } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) { + } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) { downloadButton(file: file) } if ci.meta.editable && !mc.isVoice && !live { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5242923258..e308a145b9 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1247,7 +1247,9 @@ struct ComposeView: View { } private var maxFileSize: Int64 { - getMaxFileSize(.xftp) + // the user's active badge raises the limit, but not in incognito chats where no badge is presented + let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault + return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile) } // Spec: spec/client/compose.md#sendLiveMessage diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 6b18c0c5ef..b59fd51fe8 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -220,9 +220,12 @@ struct AddGroupMembersViewCommon: View { HStack{ ProfileImage(imageStr: contact.image, size: 30) .padding(.trailing, 2) - Text(ChatInfo.direct(contact: contact).chatViewName) - .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(ChatInfo.direct(contact: contact).chatViewName) + .foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground), + contact.active ? contact.profile.localBadge : nil + ) + .lineLimit(1) Spacer() Image(systemName: icon) .foregroundColor(iconColor) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index abcadc6c3f..50144e2bc5 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -56,7 +56,7 @@ struct ChannelMembersView: View { MemberProfileImage(member, size: 38) .padding(.trailing, 2) VStack(alignment: .leading) { - displayName + NameWithBadge(displayName, member.nameBadge) .lineLimit(1) if user { Text("you") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0a448a2772..da895b325c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -502,7 +502,7 @@ struct GroupChatInfoView: View { // TODO server connection status VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) (user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member))) .lineLimit(1) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index dc14c7520b..28693e8d8a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -522,25 +522,14 @@ struct GroupMemberInfoView: View { // show alias if set, alias cannot be edited in this view let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines) let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines) - if mem.verified { - ( - Text(Image(systemName: "checkmark.shield")) - .foregroundColor(theme.colors.secondary) - .font(.title2) - + textSpace - + Text(displayName) - .font(.largeTitle) - ) + let badge = mem.nameBadge + let nameText = mem.verified + ? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2) + textSpace + Text(displayName).font(.largeTitle) + : Text(displayName).font(.largeTitle) + NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } } .multilineTextAlignment(.center) .lineLimit(2) .padding(.bottom, 2) - } else { - Text(displayName) - .font(.largeTitle) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.bottom, 2) - } if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(mem.fullName) .font(.title2) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift index 23001e64bf..74cb702d21 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -20,7 +20,7 @@ struct MemberSupportChatToolbar: View { MemberProfileImage(groupMember, size: imageSize) .padding(.trailing, 4) let t = Text(groupMember.chatViewName).font(.headline) - (groupMember.verified ? memberVerifiedShield + t : t) + NameWithBadge((groupMember.verified ? memberVerifiedShield + t : t), groupMember.nameBadge, .headline) .lineLimit(1) } .foregroundColor(theme.colors.onBackground) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 880933985c..0263a39a90 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -172,7 +172,7 @@ struct MemberSupportView: View { .padding(.trailing, 2) VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground) - (member.verified ? memberVerifiedShield + t : t) + NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) Text(memberStatus(member)) .lineLimit(1) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 243d804685..a6e7fc5870 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -173,7 +173,9 @@ struct ChatPreviewView: View { : !contact.sndReady ? theme.colors.secondary : nil - previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color) + NameWithBadge((contact.verified == true ? verifiedIcon + t : t).foregroundColor(color), chat.chatInfo.nameBadge, .title3) + .lineLimit(1) + .frame(alignment: .topLeading) case let .group(groupInfo, _): let color = if deleting { theme.colors.secondary @@ -424,11 +426,11 @@ struct ChatPreviewView: View { } case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { - CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { - CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) } case let .voice(_, duration): smallContentPreviewVoice(size: dynamicMediaSize) { @@ -436,7 +438,7 @@ struct ChatPreviewView: View { } case .file: smallContentPreviewFile(size: dynamicMediaSize) { - CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize) + CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize) } case let .chat(_, chatLink, ownerSig): smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 9276bbfc78..341bc10655 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -22,12 +22,16 @@ struct ContactRequestView: View { .padding(.leading, 4) VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { - Text(contactRequest.chatViewName) - .font(.title3) - .fontWeight(.bold) - .foregroundColor(theme.colors.primary) - .padding(.leading, 8) - .frame(alignment: .topLeading) + NameWithBadge( + Text(contactRequest.chatViewName) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(theme.colors.primary), + chat.chatInfo.nameBadge, + .title3 + ) + .padding(.leading, 8) + .frame(alignment: .topLeading) Spacer() formatTimestampText(contactRequest.updatedAt) .font(.subheadline) diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 63d28e3624..8c230dc56a 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -129,7 +129,8 @@ struct UserPicker: View { } } .padding(.trailing, 6) - Text(u.user.displayName).font(.title2).lineLimit(1) + NameWithBadge(Text(u.user.displayName).font(.title2), u.user.profile.localBadge, .title2) + .lineLimit(1) } .padding(rowPadding) .modifier(ListRow { diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index fcfcde2c07..9214e3ecde 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -200,10 +200,9 @@ struct ContactListNavLink: View { private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) - return ( - contact.verified == true - ? verifiedIcon + t - : t + return NameWithBadge( + contact.verified == true ? verifiedIcon + t : t, + chat.chatInfo.nameBadge ) .lineLimit(1) } @@ -318,8 +317,7 @@ struct ContactListNavLink: View { HStack{ ProfileImage(imageStr: chat.chatInfo.image, size: 30) - Text(chat.chatInfo.chatViewName) - .foregroundColor(color) + NameWithBadge(Text(chat.chatInfo.chatViewName).foregroundColor(color), chat.chatInfo.nameBadge) .lineLimit(1) Spacer() diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift new file mode 100644 index 0000000000..67f6d6d6b2 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -0,0 +1,174 @@ +// +// NameBadge.swift +// SimpleX +// +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// The badge is sized to a fraction of the font size (em), NOT the font's cap-height metric: the metric +// underestimates the rendered capital letters, so a cap-height-tall badge looks too small. These ratios +// are calibrated visually to match caps - the same constants as the Compose (Android/desktop) app. +private let fontCapHeightRatio: CGFloat = 0.85 +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private let badgeBaselineOffsetRatio: CGFloat = 0.05 + +// A contact/member name with the supporter badge right after it. The name keeps its own styling +// (font, weight, color, even a verification shield concatenated into the Text); the badge is sized to +// the given text style and sits on the name's baseline. Use this everywhere a name may carry a badge. +// Pass onTap to make the badge open the info alert. The badge hides itself for a nil/long-expired badge. +struct NameWithBadge: View { + let name: Text + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ name: Text, _ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.name = name + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + name + NameBadge(badge, textStyle, onTap: onTap) + } + } +} + +// The badge glyph alone, sized to the given text style and sitting on the text baseline in an +// HStack(alignment: .firstTextBaseline). Renders nothing for a nil badge or a long-expired one +// (ExpiredOld); a failed or unknown-key badge shows a warning glyph. Prefer NameWithBadge; use this +// directly only where the name is not a single Text. Pass onTap to open the badge info alert. +struct NameBadge: View { + var badge: LocalBadge? + var textStyle: UIFont.TextStyle = .body + var onTap: (() -> Void)? = nil + + init(_ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) { + self.badge = badge + self.textStyle = textStyle + self.onTap = onTap + } + + var body: some View { + if let badge, badge.status != .expiredOld { + // the leading padding is the gap to the name; it lives here so an absent badge adds no gap. + // the alignment guide pushes the badge bottom slightly below the baseline (round-letter undershoot) + let v = glyph(badge) + .frame(height: badgeHeight) + .alignmentGuide(.firstTextBaseline) { $0.height * (1 - badgeBaselineOffsetRatio) } + .padding(.leading, badgeGap) + if let onTap { + v.onTapGesture(perform: onTap) + } else { + v + } + } + } + + private var badgeHeight: CGFloat { + UIFont.preferredFont(forTextStyle: textStyle).pointSize * fontCapHeightRatio + } + + // the gap to the name, matching the verification shield's gap (textSpace - one space in the name's font) + private var badgeGap: CGFloat { + let font = UIFont.preferredFont(forTextStyle: textStyle) + return (" " as NSString).size(withAttributes: [.font: font]).width + } + + @ViewBuilder private func glyph(_ badge: LocalBadge) -> some View { + switch badge.status { + case .failed, .unknownKey: + Image(systemName: "exclamationmark.triangle.fill") + .resizable().scaledToFit() + .foregroundColor(.orange) + default: + Image(badgeImageName(badge.badge.badgeType)) + .resizable().scaledToFit() + .opacity(badge.status == .expired ? 0.4 : 1) + } + } +} + +private func badgeImageName(_ t: BadgeType) -> String { + switch t { + case .legend: "badge-legend" + case .investor: "badge-investor" + default: "badge-supporter" // supporter + unknown + } +} + +// The badge as an inline attachment for a UIKit label, for the custom alert where the name is a UILabel +// and the SwiftUI NameBadge can't be used. Sized to the font's cap height with its bottom on the baseline, +// preceded by a space for the gap to the name. Returns nil for a nil/long-expired badge. Mirrors NameBadge's glyph. +func nameBadgeAttachment(_ badge: LocalBadge?, font: UIFont) -> NSAttributedString? { + guard let badge, badge.status != .expiredOld else { return nil } + var image: UIImage? + switch badge.status { + case .failed, .unknownKey: + image = UIImage(systemName: "exclamationmark.triangle.fill")? + .withTintColor(.systemOrange, renderingMode: .alwaysOriginal) + default: + image = UIImage(named: badgeImageName(badge.badge.badgeType)) + if badge.status == .expired, let img = image { + // a recently expired badge is dimmed, matching NameBadge's 0.4 opacity + image = UIGraphicsImageRenderer(size: img.size).image { _ in + img.draw(at: .zero, blendMode: .normal, alpha: 0.4) + } + } + } + guard let image else { return nil } + let attachment = NSTextAttachment() + attachment.image = image + let h = font.pointSize * fontCapHeightRatio + // text coordinates: a negative y drops the image below the baseline by badgeBaselineOffsetRatio of its height + attachment.bounds = CGRect(x: 0, y: -h * badgeBaselineOffsetRatio, width: h * image.size.width / image.size.height, height: h) + let s = NSMutableAttributedString(string: " ") // the gap to the name + s.append(NSAttributedString(attachment: attachment)) + return s +} + +func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { + switch badge.status { + case .failed: + showAlert( + NSLocalizedString("Unverified badge", comment: "badge alert title"), + message: NSLocalizedString("This badge could not be verified and may not be genuine.", comment: "badge alert") + ) + case .unknownKey: + showAlert( + NSLocalizedString("Badge cannot be verified", comment: "badge alert title"), + message: NSLocalizedString("The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.", comment: "badge alert") + ) + default: + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + let t = badge.badge.badgeType.text + let title = t.prefix(1).uppercased() + t.dropFirst() + if case .investor = badge.badge.badgeType { + let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name) + showAlert(title, message: message) { + [ UIAlertAction(title: NSLocalizedString("Learn more", comment: "badge alert button"), style: .default) { _ in + if let url = URL(string: "https://simplex.chat/crowdfunding") { + UIApplication.shared.open(url) + } + }, + okAlertAction ] + } + } else { + // supporter, legend and unknown types use the supporter wording + let supports = + if badge.status == .expired, let expiry = badge.badge.badgeExpiry { + String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, expiry.formatted(date: .abbreviated, time: .omitted)) + } else { + String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name) + } + let v7 = NSLocalizedString("You can support SimpleX starting from v7 of the app.", comment: "badge alert") + showAlert(title, message: supports + "\n\n" + v7) + } + } +} diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 9f2fc833ba..82d17cd2b1 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat func getTopViewController() -> UIViewController? { let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene @@ -134,6 +135,7 @@ class OpenChatAlertViewController: UIViewController { private let profileName: String private let profileFullName: String private let profileImage: UIView + private let profileBadge: LocalBadge? private let subtitle: String? private let information: String? private let cancelTitle: String @@ -145,6 +147,7 @@ class OpenChatAlertViewController: UIViewController { profileName: String, profileFullName: String, profileImage: UIView, + profileBadge: LocalBadge? = nil, subtitle: String? = nil, information: String? = nil, cancelTitle: String = "Cancel", @@ -155,6 +158,7 @@ class OpenChatAlertViewController: UIViewController { self.profileName = profileName self.profileFullName = profileFullName self.profileImage = profileImage + self.profileBadge = profileBadge self.subtitle = subtitle self.information = information self.cancelTitle = cancelTitle @@ -190,12 +194,18 @@ class OpenChatAlertViewController: UIViewController { // Name label let nameLabel = UILabel() - nameLabel.text = profileName nameLabel.font = UIFont.preferredFont(forTextStyle: .headline) nameLabel.textColor = .label nameLabel.numberOfLines = 2 nameLabel.textAlignment = .center nameLabel.translatesAutoresizingMaskIntoConstraints = false + if let badge = nameBadgeAttachment(profileBadge, font: nameLabel.font) { + let s = NSMutableAttributedString(string: profileName) + s.append(badge) + nameLabel.attributedText = s + } else { + nameLabel.text = profileName + } var profileViews = [profileImage, nameLabel] @@ -365,6 +375,7 @@ func showOpenChatAlert( profileName: String, profileFullName: String, profileImage: Content, + profileBadge: LocalBadge? = nil, theme: AppTheme, subtitle: String? = nil, information: String? = nil, @@ -383,6 +394,7 @@ func showOpenChatAlert( profileName: profileName, profileFullName: profileFullName, profileImage: hostedView, + profileBadge: profileBadge, subtitle: subtitle, information: information, cancelTitle: cancelTitle, diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 4a7e50d7d2..67fd353ebc 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -560,9 +560,11 @@ private struct ActiveProfilePicker: View { HStack { ProfileImage(imageStr: user.image, size: 30) .padding(.trailing, 2) - Text(user.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) + NameWithBadge( + Text(user.chatViewName).foregroundColor(theme.colors.onBackground), + user.profile.localBadge + ) + .lineLimit(1) Spacer() if selectedProfile == user, !incognitoEnabled { Image(systemName: "checkmark") @@ -1160,6 +1162,7 @@ private func showPrepareContactAlert( : "person.crop.circle.fill", size: alertProfileImageSize ), + profileBadge: contactShortLinkData.localBadge, theme: theme, information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), @@ -1253,6 +1256,7 @@ private func showOpenKnownContactAlert( iconName: contact.chatIconName, size: alertProfileImageSize ), + profileBadge: contact.active ? contact.profile.localBadge : nil, theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a903329454..c1bc699261 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -526,12 +526,14 @@ func settingsRow(_ icon: String, color: Color/* = .secondary*/, struct ProfilePreview: View { var profileOf: NamedChat var color = Color(uiColor: .tertiarySystemGroupedBackground) + var badge: LocalBadge? = nil var body: some View { HStack { ProfileImage(imageStr: profileOf.image, size: 44, color: color) .padding(.trailing, 6) - profileName(profileOf).lineLimit(1) + NameWithBadge(profileName(profileOf), badge, .title2) + .lineLimit(1) } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2728f031b3..e2915963e4 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; + CE11BADE0000000000000002 /* NameBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE11BADE0000000000000001 /* NameBadge.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; @@ -183,8 +184,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -413,6 +414,7 @@ 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; + CE11BADE0000000000000001 /* NameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBadge.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; @@ -561,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -731,8 +733,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +820,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-Gci0HSXijHT9PyNdCZeAwi.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */, ); path = Libraries; sourceTree = ""; @@ -880,6 +882,7 @@ CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */, CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */, CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */, + CE11BADE0000000000000001 /* NameBadge.swift */, ); path = Helpers; sourceTree = ""; @@ -1559,6 +1562,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + CE11BADE0000000000000002 /* NameBadge.swift in Sources */, B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, 5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */, D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 5e0c302720..bfe25c6d42 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -136,6 +136,8 @@ public struct Profile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + // the badge proof from the wire profile - opaque to the UI, round-tripped to the core (apiPrepareContact) + public var badge: BadgeProof? public var localAlias: String { get { "" } } var profileViewName: String { @@ -158,6 +160,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { contactLink: String? = nil, preferences: Preferences? = nil, peerType: ChatPeerType? = nil, + localBadge: LocalBadge? = nil, localAlias: String ) { self.profileId = profileId @@ -168,6 +171,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.contactLink = contactLink self.preferences = preferences self.peerType = peerType + self.localBadge = localBadge self.localAlias = localAlias } @@ -179,6 +183,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var contactLink: String? public var preferences: Preferences? public var peerType: ChatPeerType? + public var localBadge: LocalBadge? public var localAlias: String var profileViewName: String { @@ -201,6 +206,70 @@ public enum ChatPeerType: String, Codable { case bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +public enum BadgeType: Hashable { + case supporter + case legend + case investor + case unknown(String) + + // the disclosed (signed) type name, shown to the user for verified badges + public var text: String { + switch self { + case .supporter: "supporter" + case .legend: "legend" + case .investor: "investor" + case let .unknown(s): s + } + } +} + +extension BadgeType: Codable { + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "supporter": self = .supporter + case "legend": self = .legend + case "investor": self = .investor + case let s: self = .unknown(s) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(text) + } +} + +public enum BadgeStatus: String, Codable { + case active + case expired + // expired over a month ago - the badge is not shown at all + case expiredOld + case failed + // signed with a key index this app version does not know - shown as a warning + case unknownKey +} + +public struct BadgeInfo: Codable, Hashable { + public var badgeType: BadgeType + public var badgeExpiry: Date? + public var badgeExtra: String +} + +public struct LocalBadge: Codable, Hashable { + public var badge: BadgeInfo + public var status: BadgeStatus +} + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +public struct BadgeProof: Codable, Hashable { + public var badgeKeyIdx: Int + public var presHeader: String + public var proof: String + public var badgeInfo: BadgeInfo +} + public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile { LocalProfile( profileId: profileId, @@ -1457,6 +1526,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none) + public var nameBadge: LocalBadge? { + get { + switch self { + case let .direct(contact): return contact.active ? contact.profile.localBadge : nil + case let .contactRequest(contactRequest): return contactRequest.profile.localBadge + default: return nil + } + } + } + public var displayName: String { get { switch self { @@ -2263,7 +2343,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public var userContactLinkId_: Int64? public var cReqChatVRange: VersionRange var localDisplayName: ContactName - var profile: Profile + public var profile: LocalProfile var createdAt: Date public var updatedAt: Date @@ -2281,7 +2361,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { userContactLinkId_: 1, cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", - profile: Profile.sampleData, + profile: LocalProfile.sampleData, createdAt: .now, updatedAt: .now ) @@ -2625,6 +2705,8 @@ public struct ContactShortLinkData: Codable, Hashable { public var profile: Profile public var message: MsgContent? public var business: Bool + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + public var localBadge: LocalBadge? } public struct GroupSummary: Decodable, Hashable { @@ -2781,6 +2863,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var fullName: String { get { memberProfile.fullName } } public var image: String? { get { memberProfile.image } } public var contactLink: String? { get { memberProfile.contactLink } } + public var nameBadge: LocalBadge? { memberProfile.localBadge } public var verified: Bool { activeConn?.connectionCode != nil } public var blocked: Bool { blockedByAdmin || !memberSettings.showMessages } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 3d0dd663c1..c44bcee5bd 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -29,6 +29,10 @@ public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB // Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +public let MAX_FILE_SIZE_XFTP_SUPPORTER: Int64 = 2_147_483_648 // 2GB +public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB + public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max public let MAX_FILE_SIZE_SMP: Int64 = 8000000 @@ -273,11 +277,26 @@ public func cleanupFile(_ aChatItem: AChatItem) { } } -public func getMaxFileSize(_ fileProtocol: FileProtocol) -> Int64 { +public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 { switch fileProtocol { - case .xftp: return MAX_FILE_SIZE_XFTP - case .smp: return MAX_FILE_SIZE_SMP - case .local: return MAX_FILE_SIZE_LOCAL + case .smp: MAX_FILE_SIZE_SMP + case .local: MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + case .xftp: + if let badge = senderProfile?.localBadge, badge.status == .active { + badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER + } else { + MAX_FILE_SIZE_XFTP + } + } +} + +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +public func ciSenderProfile(_ ci: ChatItem, _ chatInfo: ChatInfo) -> LocalProfile? { + switch (ci.chatDir, chatInfo) { + case let (.groupRcv(groupMember), _): return groupMember.memberProfile + case let (.directRcv, .direct(contact)): return contact.profile + default: return nil } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt index a09ca2792b..9649e37c0f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt @@ -95,8 +95,9 @@ fun UserPickerUserBox( } } val user = userInfo.user - Text( + NameWithBadge( user.displayName, + user.profile.localBadge, fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 11c0f9e7f6..19b36067ed 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 @@ -1430,6 +1430,17 @@ data class Chat( @Serializable sealed class ChatInfo: SomeChat, NamedChat { + // the badge shown for a chat's name: an active contact's or a contact request's (groups have none). + // a badge that expired over a month ago (ExpiredOld) is not shown at all. + val nameBadge: LocalBadge? get() { + val badge = when { + this is Direct && contact.active -> contact.profile.localBadge + this is ContactRequest -> contactRequest.profile.localBadge + else -> null + } + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } + @Serializable @SerialName("direct") data class Direct(val contact: Contact): ChatInfo() { override val chatType get() = ChatType.Direct @@ -1994,7 +2005,10 @@ data class Profile( override val localAlias : String = "", val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + // the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge), + // but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof + val badge: BadgeProof? = null ): NamedChat { val profileViewName: String get() { @@ -2022,7 +2036,8 @@ data class LocalProfile( override val localAlias: String, val contactLink: String? = null, val preferences: ChatPreferences? = null, - val peerType: ChatPeerType? = null + val peerType: ChatPeerType? = null, + val localBadge: LocalBadge? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } @@ -2046,6 +2061,70 @@ enum class ChatPeerType { @SerialName("bot") Bot } +// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. +// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. +@Serializable(with = BadgeTypeSerializer::class) +sealed class BadgeType { + @Serializable @SerialName("supporter") object Supporter: BadgeType() + @Serializable @SerialName("legend") object Legend: BadgeType() + @Serializable @SerialName("investor") object Investor: BadgeType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType() + + // the disclosed (signed) type name, shown to the user for verified badges + val text: String + get() = when (this) { + is Supporter -> "supporter" + is Legend -> "legend" + is Investor -> "investor" + is Unknown -> type + } +} + +object BadgeTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeType = + when (val v = decoder.decodeString()) { + "supporter" -> BadgeType.Supporter + "legend" -> BadgeType.Legend + "investor" -> BadgeType.Investor + else -> BadgeType.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text) +} + +@Serializable +enum class BadgeStatus { + @SerialName("active") Active, + @SerialName("expired") Expired, + // expired over a month ago - the badge is not shown at all + @SerialName("expiredOld") ExpiredOld, + @SerialName("failed") Failed, + // signed with a key index this app version does not know - shown as a warning + @SerialName("unknownKey") UnknownKey +} + +@Serializable +data class BadgeInfo( + val badgeType: BadgeType, + val badgeExpiry: Instant? = null, + val badgeExtra: String = "" +) + +@Serializable +data class LocalBadge( + val badge: BadgeInfo, + val status: BadgeStatus +) + +// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) +@Serializable +data class BadgeProof( + val badgeKeyIdx: Int, + val presHeader: String, + val proof: String, + val badgeInfo: BadgeInfo +) + @Serializable data class UserProfileUpdateSummary( val updateSuccesses: Int, @@ -2278,7 +2357,9 @@ enum class MemberCriteria { data class ContactShortLinkData ( val profile: Profile, val message: MsgContent?, - val business: Boolean + val business: Boolean, + // set by the core when building the connection plan: the link profile's badge, verified and crypto-free + val localBadge: LocalBadge? = null ) @Serializable @@ -2409,6 +2490,11 @@ data class GroupMember ( override val image: String? get() = memberProfile.image val contactLink: String? = memberProfile.contactLink val verified get() = activeConn?.connectionCode != null + // the badge shown for a member's name; a badge that expired over a month ago (ExpiredOld) is not shown + val nameBadge: LocalBadge? get() { + val badge = memberProfile.localBadge + return if (badge?.status == BadgeStatus.ExpiredOld) null else badge + } val blocked get() = blockedByAdmin || !memberSettings.showMessages override val localAlias: String = memberProfile.localAlias @@ -2727,7 +2813,7 @@ class UserContactRequest ( val contactRequestId: Long, val cReqChatVRange: VersionRange, override val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -2753,7 +2839,7 @@ class UserContactRequest ( contactRequestId = 1, cReqChatVRange = VersionRange(1, 1), localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) 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 061ea71016..97101f253e 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 @@ -710,19 +710,32 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val displayName = contact.profile.displayName.trim() + val badge = cInfo.nameBadge val text = buildAnnotatedString { if (contact.verified) { appendInlineContent(id = "shieldIcon") } append(displayName) - } - val inlineContent: Map = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun (name: String) { clipboard.setText(AnnotatedString(name)) 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 9c36f4896b..affe5ce326 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 @@ -170,9 +170,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools @Composable fun ForwardedFromSender(forwardedFromItem: AChatItem) { @Composable - fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) { - Text( + fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground, badge: LocalBadge? = null) { + NameWithBadge( text, + badge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.body1, @@ -191,13 +192,13 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (forwardedFromItem.chatItem.chatDir.sent) { ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) { - ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName) + ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, badge = forwardedFromItem.chatItem.chatDir.groupMember.nameBadge) Spacer(Modifier.height(7.dp)) - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge) } else { - ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground, badge = forwardedFromItem.chatInfo.nameBadge) } } } @@ -344,9 +345,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools ) { MemberProfileImage(size = 36.dp, member) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - Text( + NameWithBadge( member.chatViewName, - modifier = Modifier.weight(10f, fill = true), + member.nameBadge, + Modifier.weight(10f, fill = true), maxLines = 1, overflow = TextOverflow.Ellipsis ) 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 f42969a73f..68e5ee3394 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow @@ -1564,8 +1565,8 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) { ContactVerifiedShield() } - Text( - cInfo.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + cInfo.displayName, cInfo.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -2016,8 +2017,10 @@ fun BoxScope.ChatItemsList( } else { null to 1 } - Text( + // the name and the badge are one element, so SpaceBetween separates them from the role, not from each other + NameWithBadge( memberNames(member, prevMember, memCount), + if (prevMember == null && memCount == 1) member.nameBadge else null, Modifier .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) .weight(1f, false), @@ -2284,8 +2287,18 @@ fun BoxScope.ChatItemsList( .background(MaterialTheme.appColors.receivedMessage) ) { ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) + val bannerBadge = chatInfo.nameBadge + val uriHandler = LocalUriHandler.current Text( - chatInfo.displayName, + buildAnnotatedString { + append(chatInfo.displayName) + if (bannerBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (bannerBadge != null) mapOf("nameBadge" to nameBadgeInline(bannerBadge, MaterialTheme.typography.h3.fontSize) { showBadgeInfoAlert(chatInfo.displayName, bannerBadge, uriHandler) }) else emptyMap(), style = MaterialTheme.typography.h3, color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, 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 d874079238..6d598a166b 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 @@ -113,7 +113,9 @@ data class ComposeState( val inProgress: Boolean = false, val progressByTimeout: Boolean = false, val useLinkPreviews: Boolean, - val mentions: MentionedMembers = emptyMap() + val mentions: MentionedMembers = emptyMap(), + // the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch + val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP) ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( ComposeMessage( @@ -251,8 +253,6 @@ data class ComposeState( } } -private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) - sealed class RecordingState { object NotStarted: RecordingState() class Started(val filePath: String, val progressMs: Int = 0): RecordingState() @@ -300,6 +300,7 @@ fun MutableState.onFilesAttached(uris: List) { fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { + val maxFileSize = value.maxFileSize val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { val fileName = getFileName(uri) @@ -318,6 +319,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { } suspend fun MutableState.processPickedMedia(uris: List, text: String?) { + val maxFileSize = value.maxFileSize val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> @@ -487,7 +489,7 @@ fun ComposeView( if (live) { composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false) } else { - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews, maxFileSize = composeState.value.maxFileSize) resetLinkPreview() } recState.value = RecordingState.NotStarted @@ -1094,7 +1096,7 @@ fun ComposeView( if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { - composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) + composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews).copy(maxFileSize = composeState.value.maxFileSize) } } @@ -1322,6 +1324,11 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + // keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented + LaunchedEffect(chat.chatInfo) { + val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() + composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile)) + } if (appPlatform.isDesktop) { // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` DisposableEffect(Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 9298b600e9..45d336be75 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -354,9 +354,10 @@ fun ContactCheckRow( ) { ProfileImage(size = 36.dp, contact.image) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - Text( + NameWithBadge( contact.chatViewName, - modifier = Modifier.weight(10f, fill = true), + if (contact.active) contact.profile.localBadge else null, + Modifier.weight(10f, fill = true), maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 0cf3a3c96f..64f02d3376 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -94,8 +94,9 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole if (member.verified) { MemberVerifiedShield() } - Text( + NameWithBadge( member.chatViewName, + member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified 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 7b9d6aa92e..770dfa64fb 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 @@ -1078,8 +1078,10 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr if (member.verified) { MemberVerifiedShield() } - Text( - if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, + member.nameBadge, + maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } 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 8677609863..fe45be92b7 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -735,19 +736,32 @@ fun GroupMemberInfoHeader(member: GroupMember) { ) { MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val displayName = member.displayName.trim() // alias if set + val badge = member.nameBadge val text = buildAnnotatedString { if (member.verified) { appendInlineContent(id = "shieldIcon") } append(displayName) - } - val inlineContent: Map = mapOf( - "shieldIcon" to InlineTextContent( - Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) - ) { - Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + if (badge != null) { + append(" ") + appendInlineContent(id = "nameBadge") } - ) + } + val nameFontSize = MaterialTheme.typography.h1.fontSize + val uriHandler = LocalUriHandler.current + val inlineContent: Map = buildMap { + put( + "shieldIcon", + InlineTextContent( + Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) + } + ) + if (badge != null) { + put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) }) + } + } val clipboard = LocalClipboardManager.current val copyNameToClipboard = fun(name: String) { clipboard.setText(AnnotatedString(name)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 6680ef99bc..3d3096b4f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -137,8 +137,8 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic if (member.verified) { MemberVerifiedShield() } - Text( - member.displayName, fontWeight = FontWeight.SemiBold, + NameWithBadge( + member.displayName, member.nameBadge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 3d76c845ad..7ca277df94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -234,8 +234,8 @@ fun SupportChatRow(member: GroupMember) { if (member.verified) { MemberVerifiedShield() } - Text( - member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + member.chatViewName, member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (member.memberIncognito) Indigo else Color.Unspecified ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index afd55ed928..02bee37c24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -33,6 +33,7 @@ fun CIFileView( edited: Boolean, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -71,12 +72,12 @@ fun CIFileView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> { - if (fileSizeValid(file)) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } @@ -151,7 +152,7 @@ fun CIFileView( is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled)) is CIFileStatus.RcvInvitation -> - if (fileSizeValid(file)) + if (fileSizeValid(file, senderProfile)) fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) else fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) @@ -225,7 +226,9 @@ fun CIFileView( } } -fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +// whether a received file is within the size we accept from its sender +fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean = + file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile) fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 8bfbea9fa6..ed9a0e6007 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -35,6 +35,7 @@ fun CIImageView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -160,13 +161,6 @@ fun CIImageView( } } - fun fileSizeValid(): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - suspend fun imageAndFilePath(file: CIFile?): Triple? { val res = getLoadedImage(file) if (res != null) { @@ -213,12 +207,12 @@ fun CIImageView( if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> - if (fileSizeValid()) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 8289149ad9..f8dfba4c6c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -34,6 +34,7 @@ fun CIVideoView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean = false, + senderProfile: LocalProfile?, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -84,7 +85,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, senderProfile, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -114,7 +115,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) } } } } @@ -546,20 +547,13 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun fileSizeValid(file: CIFile?): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false -} - -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { - if (fileSizeValid(file)) { +private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) { + if (fileSizeValid(file, senderProfile)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile))) ) } } 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 64288d9055..2c04911e39 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 @@ -447,7 +447,7 @@ fun ChatItemView( } if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) { ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { withBGApi { Log.d(TAG, "ChatItemView downloadFileAction") 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 f55c49fdd1..5c07fe3abf 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 @@ -201,7 +201,7 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) + CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (text != "" || ci.meta.isLive) { CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -312,7 +312,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -320,7 +320,7 @@ fun FramedItemView( } } is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) + CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { 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 d749865e10..2c7e443b4d 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 @@ -152,7 +152,15 @@ fun ChatPreviewView( } else { Color.Unspecified } - chatPreviewTitleText(color = color) + NameWithBadge( + cInfo.chatViewName, + cInfo.nameBadge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold, + color = color + ) } } is ChatInfo.Group -> { @@ -316,13 +324,13 @@ fun ChatPreviewView( } } is MsgContent.MCImage -> SmallContentPreview { - CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIImageView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } } is MsgContent.MCVideo -> SmallContentPreview { - CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIVideoView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } @@ -334,7 +342,7 @@ fun ChatPreviewView( } } is MsgContent.MCFile -> SmallContentPreviewFile { - CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) { + CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) { val user = chatModel.currentUser.value ?: return@CIFileView withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 901761f65c..96e7fbacd0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -27,8 +27,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { .padding(start = 8.dp, end = 8.sp.toDp()) .weight(1F) ) { - Text( + NameWithBadge( contactRequest.chatViewName, + contactRequest.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 47668c4fb3..07058b5787 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -104,8 +104,8 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) { } else { ProfileImage(size = 42.dp, chat.chatInfo.image) } - Text( - chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + NameWithBadge( + chat.chatInfo.chatViewName, chat.chatInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (disabled) MaterialTheme.colors.secondary else if (chat.chatInfo.incognito) Indigo else Color.Unspecified ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index a02e0dc768..568cdfe574 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -210,7 +210,7 @@ fun UserPicker( } } else if (currentUser != null) { SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped) + ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge) } } } @@ -468,10 +468,11 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning image = u.image, size = 54.dp * fontSizeSqrtMultiplier ) - Text( + // the end padding is on the row, not the name, so the badge stays right after the name + NameWithBadge( u.displayName, - modifier = Modifier - .padding(start = 10.dp, end = 8.dp), + u.profile.localBadge, + Modifier.padding(start = 10.dp, end = 8.dp), color = if (enabled) MenuTextColor else MaterialTheme.colors.secondary, fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 636887275c..524fbedb1c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -54,8 +54,9 @@ fun ContactPreviewView( if (cInfo.contact.verified) { VerifiedIcon() } - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor @@ -63,8 +64,9 @@ fun ContactPreviewView( } is ChatInfo.ContactRequest -> Row(verticalAlignment = Alignment.CenterVertically) { - Text( + NameWithBadge( cInfo.chatViewName, + cInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = textColor 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 3d670d1c43..c855259ffb 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 @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.* @@ -15,10 +16,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR @@ -272,6 +275,7 @@ class AlertManager { profileName: String, profileFullName: String, profileImage: @Composable () -> Unit, + profileBadge: LocalBadge? = null, subtitle: String? = null, information: String? = null, confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat), @@ -299,8 +303,17 @@ class AlertManager { ) { profileImage() Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + val nameFontSize = MaterialTheme.typography.h4.fontSize Text( - profileName, + buildAnnotatedString { + append(profileName) + if (profileBadge != null) { + append(" ") + appendInlineContent(id = "nameBadge") + } + }, + inlineContent = + if (profileBadge != null) mapOf("nameBadge" to nameBadgeInline(profileBadge, nameFontSize)) else emptyMap(), textAlign = TextAlign.Center, style = MaterialTheme.typography.h4, lineHeight = 20.sp, 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 5f3a73e7ea..d2ee1db09c 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 @@ -3,10 +3,14 @@ package chat.simplex.common.views.helpers import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.shape.* import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -14,15 +18,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.BadgeType import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.LocalBadge +import chat.simplex.common.model.localDate import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlin.math.max +import kotlin.math.roundToInt @Composable fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) { @@ -103,6 +120,137 @@ fun ProfileImage( } } +// badge height in em: calibrated visually so the badge top matches capital letters and digits +// (Inter's declared cap height is 2048/2816 = 0.727em, but the rendered text is taller than the metrics predict) +private const val fontCapHeightRatio = 0.95f + +// fraction of the badge height pushed below the text baseline (like the undershoot of round letters) +private const val badgeBaselineOffsetRatio = 0.05f + +// the badge glyph's width / height (the SVGs are cropped to the glyph: 300 x 399) +private const val badgeAspectRatio = 300f / 399f + +// A contact/member name with the badge right after it: the badge is baseline-aligned with the name +// and sized to its font (fontSize if given, otherwise style.fontSize), and a truncated name keeps it visible. +@Composable +fun NameWithBadge( + name: String, + badge: LocalBadge?, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current +) { + Row(modifier) { + Text( + name, + Modifier.alignByBaseline().weight(1f, fill = false), + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + overflow = overflow, + maxLines = maxLines, + style = style + ) + NameBadge(badge, if (fontSize.isSpecified) fontSize else style.fontSize) + } +} + +// Badge next to the contact name in a Row: top aligned with capital letters, bottom just below the text baseline. +// Use NameWithBadge unless the row needs special arrangement; then the name Text must use Modifier.alignByBaseline(). +@Composable +fun RowScope.NameBadge(badge: LocalBadge?, fontSize: TextUnit = LocalTextStyle.current.fontSize) { + // a badge that expired over a month ago (ExpiredOld) is not shown + if (badge == null || badge.status == BadgeStatus.ExpiredOld) return + val height = with(LocalDensity.current) { (if (fontSize.isSpecified) fontSize else 14.sp).toDp() } * fontCapHeightRatio + BadgeGlyph( + badge, + // the alignment line sits badgeBaselineOffsetRatio above the badge's bottom edge, + // so the Row places the badge that much below the text baseline; + // 6.dp matches the visible gap between the name and the verification shield: + // the shield has 3.dp end padding plus ~17% internal glyph margin, the badge artwork has none + Modifier.alignBy { (it.measuredHeight * (1 - badgeBaselineOffsetRatio)).roundToInt() }.padding(start = 6.dp).height(height).aspectRatio(badgeAspectRatio) + ) +} + +// badge inside a Text via appendInlineContent(id): bottom on the baseline, cap-height tall. +// precede with append(" ") for the space between the name and the badge. +fun nameBadgeInline(badge: LocalBadge, fontSize: TextUnit, onBadgeClick: (() -> Unit)? = null): InlineTextContent { + val height = fontSize * fontCapHeightRatio + return InlineTextContent( + Placeholder(height * badgeAspectRatio, height, PlaceholderVerticalAlign.AboveBaseline) + ) { + // the placeholder bottom sits on the baseline and can't extend below it, + // so the badge is drawn shifted down by badgeBaselineOffsetRatio instead + BadgeGlyph(badge, Modifier.fillMaxSize().graphicsLayer { translationY = size.height * badgeBaselineOffsetRatio }, onBadgeClick) + } +} + +@Composable +private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() -> Unit)? = null) { + val mod = modifier.let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it } + if (badge.status == BadgeStatus.Failed || badge.status == BadgeStatus.UnknownKey) { + Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod) + } else { + Image( + painterResource(badgeImage(badge.badge.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f, + modifier = mod + ) + } +} + +fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) { + // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title + val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() } + when { + badge.status == BadgeStatus.Failed -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unverified_title), + text = generalGetString(MR.strings.badge_unverified_desc) + ) + badge.status == BadgeStatus.UnknownKey -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badge_unknown_key_title), + text = generalGetString(MR.strings.badge_unknown_key_desc) + ) + badge.badge.badgeType is BadgeType.Investor -> + AlertManager.shared.showAlertDialog( + title = title, + text = String.format(generalGetString(MR.strings.badge_invested), name), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.learn_more), + onDismiss = { uriHandler.openUriCatching("https://simplex.chat/crowdfunding") } + ) + else -> { + // Supporter, Legend and unknown types use the supporter wording + val expiry = badge.badge.badgeExpiry + val supports = + if (badge.status == BadgeStatus.Expired && expiry != null) + String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry)) + else + String.format(generalGetString(MR.strings.badge_supports_simplex), name) + AlertManager.shared.showAlertMsg( + title = title, + text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7) + ) + } + } +} + +private fun badgeImage(t: BadgeType): ImageResource = when (t) { + is BadgeType.Legend -> MR.images.badge_legend + is BadgeType.Investor -> MR.images.badge_investor + else -> MR.images.badge_supporter // Supporter + Unknown +} + @Composable fun ProfileImage(size: Dp, image: ImageResource) { Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 23c622bc34..86f2f13313 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000 const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB +// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge +const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB +const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB + const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI @@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair { // count, size in return fileCount to bytes } -fun getMaxFileSize(fileProtocol: FileProtocol): Long { - return when (fileProtocol) { - FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP - FileProtocol.SMP -> MAX_FILE_SIZE_SMP - FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL +fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) { + FileProtocol.SMP -> MAX_FILE_SIZE_SMP + FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL + // a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB + FileProtocol.XFTP -> { + val badge = senderProfile?.localBadge + if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP + else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND + else MAX_FILE_SIZE_XFTP_SUPPORTER } } +// the profile of whoever sent a received chat item - the group member, or the direct chat's contact +fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) { + is CIDirection.GroupRcv -> dir.groupMember.memberProfile + is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile + else -> null +} + expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration fun showWrongUriAlert() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 9fd5dd5b4a..e5dbe01d68 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -474,6 +474,8 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: icon = contact.chatIconName ) }, + // the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too + profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null, confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat), onConfirm = { openKnownContact(chatModel, rhId, close, contact) @@ -633,6 +635,7 @@ fun showPrepareContactAlert( else MR.images.ic_account_circle_filled ) }, + profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge, information = ownerVerificationMessage(ownerVerification), confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), onConfirm = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index b1ab8eb24e..be16ced1f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -230,7 +230,8 @@ private fun ProfilePickerOption( disabled: Boolean, onSelected: () -> Unit, image: @Composable () -> Unit, - onInfo: (() -> Unit)? = null + onInfo: (() -> Unit)? = null, + badge: LocalBadge? = null ) { Row( Modifier @@ -243,7 +244,7 @@ private fun ProfilePickerOption( ) { image() TextIconSpaced(false) - Text(title, modifier = Modifier.align(Alignment.CenterVertically)) + NameWithBadge(title, badge, Modifier.align(Alignment.CenterVertically)) if (onInfo != null) { Spacer(Modifier.padding(6.dp)) Column(Modifier @@ -365,7 +366,8 @@ fun ActiveProfilePicker( } } }, - image = { ProfileImage(size = 42.dp, image = user.image) } + image = { ProfileImage(size = 42.dp, image = user.image) }, + badge = user.profile.localBadge ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index a02d67265d..22270ea5bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -309,12 +309,13 @@ fun AppVersionItem(showVersion: () -> Unit) { Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else "")) } -@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) { +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false, badge: LocalBadge? = null) { ProfileImage(size = size, image = profileOf.image, color = iconColor) Spacer(Modifier.padding(horizontal = 8.dp)) Column(Modifier.height(size), verticalArrangement = Arrangement.Center) { - Text( + NameWithBadge( profileOf.displayName, + badge, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = if (stopped) MaterialTheme.colors.secondary else textColor, 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 cd0508f95a..ecca74fae2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3105,4 +3105,12 @@ SimpleX — %d unread Minimize to tray when closing window Keep SimpleX running in the background to receive messages. + %s supports SimpleX Chat. + %1$s supported SimpleX Chat. The badge expired on %2$s. + You can support SimpleX starting from v7 of the app. + %s invested in SimpleX Chat crowdfunding. + Unverified badge + This badge could not be verified and may not be genuine. + Badge cannot be verified + The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg new file mode 100644 index 0000000000..330da9b50d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg new file mode 100644 index 0000000000..7f892cd25c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg new file mode 100644 index 0000000000..9ebdc15c11 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt index 52e845b422..43428bab72 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -67,15 +66,17 @@ actual fun UserPickerUsersSection( } } - Text( - user.displayName, - fontSize = 12.sp, - fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(65.dp), - textAlign = TextAlign.Center - ) + Row(Modifier.width(65.dp), horizontalArrangement = Arrangement.Center) { + Text( + user.displayName, + fontSize = 12.sp, + fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alignByBaseline().weight(1f, fill = false) + ) + NameBadge(user.profile.localBadge, 12.sp) + } } } } diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 41321edc68..f0501fef4f 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -1,8 +1,14 @@ module Main where import Server (simplexChatServer) +import Simplex.Chat.Badges.CLI (runBadgeCommand) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI) +import System.Environment (getArgs) main :: IO () -main = simplexChatCLI terminalChatConfig (Just simplexChatServer) +main = do + args <- getArgs + case args of + ("badge" : _) -> runBadgeCommand args + _ -> simplexChatCLI terminalChatConfig (Just simplexChatServer) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 4036bd8cf1..89c5178f7d 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -313,43 +313,48 @@ getGroupReg_ db gId = getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) getGroupAndReg cc user@User {userId, userContactId} gId = - withDB "getGroupAndReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ - DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + withDB "getGroupAndReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = - withDB "getUserGroupReg" cc $ \db -> - ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ + withDB "getUserGroupReg" cc $ \db -> do + currentTs <- liftIO getCurrentTime + ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $ DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) getUserGroupRegs cc user@User {userId, userContactId} ctId = - withDB' "getUserGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] -getAllListedGroups_ db cxt user@User {userId, userContactId} = +getAllListedGroups_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg cxt user) + >>= mapM (withGroupLink . toGroupInfoReg currentTs cxt user) where withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = - withDB' "searchListedGroups" cc $ \db -> + withDB' "searchListedGroups" cc $ \db -> do + currentTs <- getCurrentTime case searchType of STAll -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -357,11 +362,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " STRecent -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) n <- count $ DB.query db countQuery' (Only GRSActive) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId) pure (gs, n) where @@ -369,11 +374,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC " STSearch search -> case lastGroup_ of Nothing -> do - gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) pure (gs, n) Just gId -> do - gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s) pure (gs, n) where @@ -381,7 +386,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - groups = (map (toGroupInfoReg (storeCxt cc) user) <$>) + groups currentTs = (map (toGroupInfoReg currentTs (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -395,21 +400,24 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa |] getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)] -getAllGroupRegs_ db cxt user@User {userId, userContactId} = - map (toGroupInfoReg cxt user) +getAllGroupRegs_ db cxt user@User {userId, userContactId} = do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs cxt user) <$> DB.query db groupReqQuery (userId, userContactId) getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = - withDB' "getDuplicateGroupRegs" cc $ \db -> - map (toGroupInfoReg (storeCxt cc) user) + withDB' "getDuplicateGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listLastGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" pure (gs, n) @@ -417,15 +425,16 @@ listLastGroups cc user@User {userId, userContactId} count = listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) listPendingGroups cc user@User {userId, userContactId} count = withDB' "getUserGroupRegs" cc $ \db -> do + currentTs <- getCurrentTime gs <- - map (toGroupInfoReg (storeCxt cc) user) + map (toGroupInfoReg currentTs (storeCxt cc) user) <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg cxt User {userContactId} (groupRow :. grRow) = - (toGroupInfo cxt userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg currentTs cxt User {userContactId} (groupRow :. grRow) = + (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index a87bcae5e4..60cee67d78 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -10,6 +10,10 @@ This file is generated automatically. - [AgentCryptoError](#agentcryptoerror) - [AgentErrorType](#agenterrortype) - [AutoAccept](#autoaccept) +- [BadgeInfo](#badgeinfo) +- [BadgeProof](#badgeproof) +- [BadgeStatus](#badgestatus) +- [BadgeType](#badgetype) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -122,6 +126,7 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [LocalBadge](#localbadge) - [LocalProfile](#localprofile) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) @@ -353,6 +358,49 @@ INACTIVE: - acceptIncognito: bool +--- + +## BadgeInfo + +**Record type**: +- badgeType: [BadgeType](#badgetype) +- badgeExpiry: UTCTime? +- badgeExtra: string + + +--- + +## BadgeProof + +**Record type**: +- badgeKeyIdx: int +- presHeader: string +- proof: string +- badgeInfo: [BadgeInfo](#badgeinfo) + + +--- + +## BadgeStatus + +**Enum type**: +- "active" +- "expired" +- "expiredOld" +- "failed" +- "unknownKey" + + +--- + +## BadgeType + +**Enum type**: +- "supporter" +- "legend" +- "investor" + + --- ## BlockingInfo @@ -1766,6 +1814,7 @@ ContactViaAddress: - profile: [Profile](#profile) - message: [MsgContent](#msgcontent)? - business: bool +- localBadge: [LocalBadge](#localbadge)? --- @@ -2672,6 +2721,15 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## LocalBadge + +**Record type**: +- badge: [BadgeInfo](#badgeinfo) +- status: [BadgeStatus](#badgestatus) + + --- ## LocalProfile @@ -2685,6 +2743,7 @@ Unknown: - contactLink: string? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- localBadge: [LocalBadge](#localbadge)? - localAlias: string @@ -3029,6 +3088,7 @@ count= - contactLink: string? - preferences: [Preferences](#preferences)? - peerType: [ChatPeerType](#chatpeertype)? +- badge: [BadgeProof](#badgeproof)? --- @@ -4213,7 +4273,7 @@ Handshake: - cReqChatVRange: [VersionRange](#versionrange) - localDisplayName: string - profileId: int64 -- profile: [Profile](#profile) +- profile: [LocalProfile](#localprofile) - createdAt: UTCTime - updatedAt: UTCTime - xContactId: string? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 8894609758..1cd7c78913 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -202,6 +202,7 @@ cliCommands = "AbortSwitchGroupMember", "AcceptContact", "AcceptMember", + "AddBadge", "AddContact", "AddMember", "AllowRelayGroup", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 8397503bbe..7b268f4ec5 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -34,6 +34,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), BadgeType (..), JSONBadge (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -183,6 +184,7 @@ ciQuoteType = chatTypesDocsData :: [(SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text)] chatTypesDocsData = [ ((sti @(Chat 'CTDirect)) {typeName = "AChat"}, STRecord, "", [], "", ""), + ((sti @JSONBadge) {typeName = "LocalBadge"}, STRecord, "", [], "", ""), ((sti @JSONChatInfo) {typeName = "ChatInfo"}, STUnion, "JCInfo", ["JCInfoInvalidJSON"], "", ""), ((sti @JSONCIContent) {typeName = "CIContent"}, STUnion, "JCI", ["JCIInvalidJSON"], "", ""), ((sti @JSONCIDeleted) {typeName = "CIDeleted"}, STUnion, "JCID", [], "", ""), @@ -207,6 +209,7 @@ chatTypesDocsData = (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), (sti @AutoAccept, STRecord, "", [], "", ""), + (sti @BadgeProof, STRecord, "", [], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), @@ -216,6 +219,8 @@ chatTypesDocsData = (sti @ChatDeleteMode, STUnion, "CDM", [], Param "type" <> Choice "self" [("messages", "")] (OnOffParam "notify" "notify" (Just True)), ""), (sti @ChatError, STUnion, "Chat", ["ChatErrorDatabase", "ChatErrorRemoteHost", "ChatErrorRemoteCtrl"], "", ""), (sti @ChatErrorType, STUnion, "CE", ["CEContactNotFound", "CEServerProtocol", "CECallState", "CEInvalidChatMessage"], "", ""), + (sti @BadgeStatus, STEnum, "BS", [], "", ""), + (sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""), (sti @ChatFeature, STEnum, "CF", [], "", ""), (sti @ChatItemDeletion, STRecord, "", [], "", "Message deletion result."), (sti @ChatPeerType, STEnum, "CPT", [], "", ""), @@ -303,6 +308,7 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), @@ -422,11 +428,14 @@ deriving instance Generic AddressSettings deriving instance Generic AgentCryptoError deriving instance Generic AgentErrorType deriving instance Generic AutoAccept +deriving instance Generic BadgeProof deriving instance Generic BlockingInfo deriving instance Generic BlockingReason deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType +deriving instance Generic BadgeStatus +deriving instance Generic BadgeType deriving instance Generic ChatBotCommand deriving instance Generic ChatDeleteMode deriving instance Generic ChatError @@ -515,6 +524,7 @@ deriving instance Generic HandshakeError deriving instance Generic InlineFileMode deriving instance Generic InvitationLinkPlan deriving instance Generic InvitedBy +deriving instance Generic JSONBadge deriving instance Generic JSONChatInfo deriving instance Generic JSONCIContent deriving instance Generic JSONCIDeleted @@ -524,6 +534,7 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic BadgeInfo deriving instance Generic LocalProfile deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 36e87db62d..8dfba2bbb0 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -198,7 +198,11 @@ toTypeInfo tr = "AgentInvId", "AgentRcvFileId", "AgentSndFileId", + "BadgeMasterKey", "B64UrlByteString", + "BBSProof", + "BBSPresHeader", + "BBSSignature", "CbNonce", "ConnectionLink", "ConnShortLink", diff --git a/cabal.project b/cabal.project index 3e32dfcd5e..d3b9eeffa5 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7 + tag: 9f9b6c8e88524fb5fd063f47617a679ea53ac7c0 source-repository-package type: git diff --git a/flake.nix b/flake.nix index 43f4e8912a..fdd041bd88 100644 --- a/flake.nix +++ b/flake.nix @@ -406,6 +406,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -525,6 +527,8 @@ "chat_send_remote_cmd_retry" "chat_valid_name" "chat_json_length" + "chat_badge_keygen" + "chat_badge_issue" "chat_write_file" ]; postInstall = '' @@ -591,6 +595,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -607,6 +612,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -626,6 +632,7 @@ packages.simplex-chat.flags.swift = true; packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; @@ -641,6 +648,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; + packages.simplexmq.flags.commoncrypto = true; packages.entropy.flags.DoNotGetEntropy = true; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 76e6f9f3ee..ec4125193f 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -16,6 +16,8 @@ EXPORTS chat_password_hash chat_valid_name chat_json_length + chat_badge_keygen + chat_badge_issue chat_encrypt_media chat_decrypt_media chat_write_file diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 5e671169de..883728f943 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -186,6 +186,33 @@ export interface AutoAccept { acceptIncognito: boolean } +export interface BadgeInfo { + badgeType: BadgeType + badgeExpiry?: string // ISO-8601 timestamp + badgeExtra: string +} + +export interface BadgeProof { + badgeKeyIdx: number // int + presHeader: string + proof: string + badgeInfo: BadgeInfo +} + +export enum BadgeStatus { + Active = "active", + Expired = "expired", + ExpiredOld = "expiredOld", + Failed = "failed", + UnknownKey = "unknownKey", +} + +export enum BadgeType { + Supporter = "supporter", + Legend = "legend", + Investor = "investor", +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -2044,6 +2071,7 @@ export interface ContactShortLinkData { profile: Profile message?: MsgContent business: boolean + localBadge?: LocalBadge } export enum ContactStatus { @@ -2940,6 +2968,11 @@ export interface LinkPreview { content?: LinkContent } +export interface LocalBadge { + badge: BadgeInfo + status: BadgeStatus +} + export interface LocalProfile { profileId: number // int64 displayName: string @@ -2949,6 +2982,7 @@ export interface LocalProfile { contactLink?: string preferences?: Preferences peerType?: ChatPeerType + localBadge?: LocalBadge localAlias: string } @@ -3299,6 +3333,7 @@ export interface Profile { contactLink?: string preferences?: Preferences peerType?: ChatPeerType + badge?: BadgeProof } export type ProxyClientError = @@ -4882,7 +4917,7 @@ export interface UserContactRequest { cReqChatVRange: VersionRange localDisplayName: string profileId: number // int64 - profile: Profile + profile: LocalProfile createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp xContactId?: string diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 66ba77c062..855a967215 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -138,6 +138,21 @@ AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FI class AutoAccept(TypedDict): acceptIncognito: bool +class BadgeInfo(TypedDict): + badgeType: "BadgeType" + badgeExpiry: NotRequired[str] # ISO-8601 timestamp + badgeExtra: str + +class BadgeProof(TypedDict): + badgeKeyIdx: int # int + presHeader: str + proof: str + badgeInfo: "BadgeInfo" + +BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] + +BadgeType = Literal["supporter", "legend", "investor"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -1441,6 +1456,7 @@ class ContactShortLinkData(TypedDict): profile: "Profile" message: NotRequired["MsgContent"] business: bool + localBadge: NotRequired["LocalBadge"] ContactStatus = Literal["active", "deleted", "deletedByUser"] @@ -2059,6 +2075,10 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class LocalBadge(TypedDict): + badge: "BadgeInfo" + status: "BadgeStatus" + class LocalProfile(TypedDict): profileId: int # int64 displayName: str @@ -2068,6 +2088,7 @@ class LocalProfile(TypedDict): contactLink: NotRequired[str] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + localBadge: NotRequired["LocalBadge"] localAlias: str MemberCriteria = Literal["all"] @@ -2318,6 +2339,7 @@ class Profile(TypedDict): contactLink: NotRequired[str] preferences: NotRequired["Preferences"] peerType: NotRequired["ChatPeerType"] + badge: NotRequired["BadgeProof"] class ProxyClientError_protocolError(TypedDict): type: Literal["protocolError"] @@ -3431,7 +3453,7 @@ class UserContactRequest(TypedDict): cReqChatVRange: "VersionRange" localDisplayName: str profileId: int # int64 - profile: "Profile" + profile: "LocalProfile" createdAt: str # ISO-8601 timestamp updatedAt: str # ISO-8601 timestamp xContactId: NotRequired[str] diff --git a/plans/2026-06-01-supporter-badges-v1.md b/plans/2026-06-01-supporter-badges-v1.md new file mode 100644 index 0000000000..29a47a103e --- /dev/null +++ b/plans/2026-06-01-supporter-badges-v1.md @@ -0,0 +1,80 @@ +# Supporter Badges v1 - Verification + +Badge verification in stable so that v6.5 users can see and verify badges from v7 users. Badge purchase and issuance is v2. + +## Why BBS+ + +BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures) allow a holder of a signed credential to generate zero-knowledge proofs that selectively disclose some signed attributes while hiding others. Each proof uses a random nonce, making different proofs from the same credential computationally unlinkable - a verifier seeing two proofs cannot determine they came from the same credential. This means a supporter badge shown to different contacts cannot be correlated, preserving SimpleX's unlinkable identity model. + +The server that signs the credential sees the master secret during signing but cannot link any received proof back to any signing session - this is the core zero-knowledge property. + +## References + +- IETF draft: https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/ +- libbbs: https://github.com/Fraunhofer-AISEC/libbbs (Apache-2.0, Fraunhofer-AISEC) +- blst: https://github.com/supranational/blst (Apache-2.0, audited by NCC Group) - internal dependency of libbbs for BLS12-381 curve operations + +Both are vendored verbatim into simplexmq so that users and maintainers can verify the source matches upstream. Only libbbs API is called directly. + +## Crypto + +3 signed messages: `[ms, expiry, level]`. `ms` undisclosed (index 0), `expiry` and `level` disclosed (indexes 1, 2). Proof size: 304 bytes (272 base + 32 per undisclosed). + +Server public key (`srvPK`, 96 bytes) hardcoded in app. + +## libbbs integration + +Vendor libbbs + blst C sources into simplexmq. Haskell FFI bindings following the SNTRUP761 pattern (`Simplex.Messaging.Crypto.BBS.Bindings`). + +Full FFI surface for testing the complete flow: + +- `bbs_keygen_full` - generate keypair +- `bbs_sign` - sign messages +- `bbs_proof_gen` - generate ZK proof with selective disclosure +- `bbs_proof_verify` - verify proof +- `bbs_sha256_ciphersuite` - ciphersuite constant + +Unit tests: keygen, sign, proof gen, proof verify roundtrip. Verify proof size. Verify rejection of tampered proofs. Verify two proofs from same credential don't correlate (different presentation headers produce different proofs that both verify). + +Use blst portable C fallback for now (avoids per-arch assembly). + +## Profile type + +Add optional `badge` field to `Profile`. The `SupporterBadge` type uses base64-encoded newtypes for binary fields, following the `KEMPublicKey`/`KEMCiphertext` pattern from SNTRUP761 bindings: + +```haskell +data SupporterBadge = SupporterBadge + { proof :: BBSProof + , proofNonce :: ByteString + , badgeExpiry :: UTCTime + , badgeType :: Text + } +``` + +`badgeType` is a string: `"supporter"`, `"business"`, `"legend"`, `"cf_investor"`. Displayed in UI as Supporter, Business, Legend, Crowdfunding Investor. `BBSProof` is a newtype over `ByteString` with `StrEncoding` instances for base64url JSON encoding. + +Backward compatible: `omitNothingFields` means older clients ignore it, newer clients without badge send `Nothing`. + +## DB + +- `badge` fields on `contact_profiles` and `group_member_profiles` to store received badge data +- `badge_status` column on `contacts` and `group_members` to store verification result +- `badge` fields on user profile (`users` or `contact_profiles` for own profile) for when badge issuance is added in v2 + +## Verification + +On receiving profile with `badge` (in Subscriber.hs, `XInfo`/`XGrpMemInfo`/`XContact` handlers): + +1. `bbs_proof_verify(srvPK, proof, "", proofNonce, disclosed=[1,2], [expiry, level])` +2. Check `expiry >= now` +3. Store badge + verification status on contact/member + +## UI + +Badge icon next to display name for verified contacts/members. Different icons per level string. Expired badges shown differently or hidden. + +## Not in v1 + +- Badge purchase, issuance, credential storage, proof generation - v2 +- Service framework - v2 +- Payment platform integration - v2 diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3610906390..ac230b7af1 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b981dcb70b8c99dc3733a99b6c1dc0d0ef83a3f7" = "0wpri01w30rd3wwzw630yngnj9fmyb7rschl3ic1cjd926vpg9b7"; + "https://github.com/simplex-chat/simplexmq.git"."9f9b6c8e88524fb5fd063f47617a679ea53ac7c0" = "01jdjndx0h2ardzi9dd21q0n36lvwbdkhp7nzdrz01c3hh0br9bd"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f3612c88cf..3a1a8ff24b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -38,6 +38,8 @@ library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings + Simplex.Chat.Badges + Simplex.Chat.Badges.CLI Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Delivery @@ -51,6 +53,7 @@ library Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Mobile + Simplex.Chat.Mobile.Badges Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC @@ -134,6 +137,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access + Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges else exposed-modules: Simplex.Chat.Archive @@ -290,6 +294,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access + Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges other-modules: Paths_simplex_chat hs-source-dirs: @@ -550,6 +555,7 @@ test-suite simplex-chat-test main-is: Test.hs other-modules: APIDocs + BadgeTests Bots.BroadcastTests Bots.DirectoryTests ChatClient diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c3658a1c94..5adaaca150 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,6 +29,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import Data.Time.Clock (getCurrentTime, nominalDay) import Simplex.Chat.Controller +import Simplex.Chat.Badges (BBSPublicKeyStr (..)) import Simplex.Chat.Library.Commands import Simplex.Chat.Operators import Simplex.Chat.Operators.Presets @@ -65,6 +66,17 @@ defaultChatConfig = tbqSize = 1024 }, chatVRange = supportedChatVRange, + badgePublicKeys = + M.fromList + [ (1, toBBSPublicKey "mW_5Zp1wHnXDF56wOZwFcRjGrf0GLLsfyymIQDqYoWfjfvS7oQWSfi7hH65N8JhuE9x8wbKXHidnQLO4GnOSMP_bRKUMH1qIzv5SQKFHNM8G4PaWcTcri8iZLc-3xhSI"), + (2, toBBSPublicKey "odGCB7uVDXTURsHgSvSciByV4Q3-3ZvEB8myDsDJqm-PwOYc5-At36uc7n_pyUDxEQEHr9i4RJgFih2FSArPW-EQBXNPNf4wTtA0znn74qLEGc4fh9pVYPEIm_ZGbnsJ"), + (3, toBBSPublicKey "txkT2003WMjc43KvYvPKEcR970NLmw5UZY51eUqgk91sgp53idt1HTlKYvnrEttJDFMlctYf1-bpri0e9DhBQ-xk1J4WoLN2uif_1OcA1pGCobpk9lwtsq1Idek4biy0"), + (4, toBBSPublicKey "q_YzegihaLYrEm9z3cAghsfDGNZfXuEpQGMJERJQS4M0Szl4gvSC_fV_muKc3NIMA_8iYuBN8qyvb5U55RctCRn3kleFQ4sqf-WBgoydX6UVo7BsYcUbXWWEFZXlOGIH"), + (5, toBBSPublicKey "oqymHASH_okefShrnz4HnTooUNlE1WoDRnSrgd0bTCpOacgJWBsMpwZpdmYlX-vQAKAC_zmI4VdKoOznnhW-sdUXZw6bthCi5JYjGxCR1Co27i1tix5UXCTbR5Jp901-"), + (6, toBBSPublicKey "kDqaB6zKSRp_97QPFj5JPDlo0vzfSTLSp9goFx1qajv4q4H6dR6BbkmWZ4xx_9Q2AxmcpqcV0ethz1OH-Jk_Sz2J1mIz1PUVM9LkdLhi_PNtqhezzO5dbVs-HJ1fNqe6"), + (7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"), + (8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd") + ], confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs new file mode 100644 index 0000000000..e861d27f11 --- /dev/null +++ b/src/Simplex/Chat/Badges.hs @@ -0,0 +1,414 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE ExistentialQuantification #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Badges + ( BadgeType (..), + BadgeStatus (..), + BadgeInfo (..), + BadgeCredential (..), + BadgeProof (..), + LocalBadge (..), + JSONBadge (..), + BBSPublicKeyStr (..), + localBadgeInfo, + localBadgeStatus, + maxXFTPFileSize, + maxFileSizeSupporter, + maxFileSizeLegend, + BadgePresHeaderTag (..), + BadgePresHeader (..), + BadgePurchase (..), + BadgeMasterKey (..), + BadgeRequest (..), + VerifiedBadgeRequest (..), + bbsBadgeHeader, + generateMasterKey, + verifyPayment, + issueBadge, + verifyCredential, + generateBadgeProof, + badgeProof, + verifyBadge, + verifyBadge_, + mkBadgeStatus, + BadgeRow, + badgeToRow, + localBadgeToRow, + rowToBadge, + ) where + +import Control.Concurrent.STM +import Crypto.Random (ChaChaDRG) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Either (fromRight) +import Data.Int (Int64) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.String +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, nominalDay) +import Simplex.FileTransfer.Description (gb, maxFileSize) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif + +-- Badge type + +data BadgeType + = BTSupporter + | BTLegend + | BTInvestor + | BTUnknown Text + deriving (Eq, Show) + +instance TextEncoding BadgeType where + textEncode = \case + BTSupporter -> "supporter" + BTLegend -> "legend" + BTInvestor -> "investor" + BTUnknown tag -> tag + textDecode s = Just $ case s of + "supporter" -> BTSupporter + "legend" -> BTLegend + "investor" -> BTInvestor + tag -> BTUnknown tag + +instance ToJSON BadgeType where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeType where + parseJSON = textParseJSON "BadgeType" + +-- Badge status + +data BadgeStatus = BSActive | BSExpired | BSExpiredOld | BSFailed | BSUnknownKey + deriving (Eq, Show) + +-- Disclosed badge content (BBS messages 1, 2, 3) + +data BadgeInfo = BadgeInfo + { badgeType :: BadgeType, + badgeExpiry :: Maybe UTCTime, + badgeExtra :: Text + } + deriving (Eq, Show) + +-- a badge expired longer than this ago is BSExpiredOld and is not shown in the UI +badgeOldInterval :: NominalDiffTime +badgeOldInterval = 31 * nominalDay + +-- the verification outcome of a received proof: Just True = verified, Just False = failed, +-- Nothing = the proof's key index is not among this app version's configured keys (BSUnknownKey). +mkBadgeStatus :: UTCTime -> Maybe Bool -> BadgeInfo -> BadgeStatus +mkBadgeStatus now verified BadgeInfo {badgeExpiry} = case verified of + Nothing -> BSUnknownKey + Just False -> BSFailed + Just True -> case badgeExpiry of + Just e + | addUTCTime badgeOldInterval e < now -> BSExpiredOld + | e < now -> BSExpired + _ -> BSActive + +-- A badge credential (own, secret) and a proof (a presentation) are independent records. +-- badgeKeyIdx is the issuer key index: it tells verifiers which configured key to use. +-- Only proofs ride the wire (in a profile); credentials come from the badge service. Neither is +-- ever serialized as a sum - each travels as its own record, so the JSON carries no credential/proof tag. + +data BadgeCredential = BadgeCredential + { badgeKeyIdx :: Int, + masterKey :: BadgeMasterKey, + signature :: BBSSignature, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +data BadgeProof = BadgeProof + { badgeKeyIdx :: Int, + presHeader :: BBSPresHeader, + proof :: BBSProof, + badgeInfo :: BadgeInfo + } + deriving (Eq, Show) + +-- Local badge: a stored badge plus its display status (the in-memory sum; never serialized as a sum). +-- OwnBadge - the user's own credential (loaded from the DB). +-- PeerBadge - a verified peer proof (from the DB, or received over the wire). +-- ShownBadge - decoded from a crypto-free profile JSON for display only: no crypto, so it cannot be sent. +data LocalBadge + = OwnBadge BadgeCredential BadgeStatus + | PeerBadge BadgeProof BadgeStatus + | ShownBadge BadgeInfo BadgeStatus + deriving (Eq, Show) + +localBadgeInfo :: LocalBadge -> BadgeInfo +localBadgeInfo = \case + OwnBadge BadgeCredential {badgeInfo} _ -> badgeInfo + PeerBadge BadgeProof {badgeInfo} _ -> badgeInfo + ShownBadge i _ -> i + +localBadgeStatus :: LocalBadge -> BadgeStatus +localBadgeStatus = \case + OwnBadge _ st -> st + PeerBadge _ st -> st + ShownBadge _ st -> st + +-- XFTP file size limit raised by an active badge: a legend badge to 5GB, any other to 2GB, otherwise the default. +maxFileSizeSupporter :: Int64 +maxFileSizeSupporter = gb 2 + +maxFileSizeLegend :: Int64 +maxFileSizeLegend = gb 5 + +maxXFTPFileSize :: Maybe LocalBadge -> Int64 +maxXFTPFileSize = \case + Just b | localBadgeStatus b == BSActive -> case badgeType (localBadgeInfo b) of + BTLegend -> maxFileSizeLegend + _ -> maxFileSizeSupporter + _ -> maxFileSize + +-- Presentation header: a tag char + payload. PHTest is unbound - a fresh random nonce per +-- presentation, not bound to any context; the 'T' tag marks it so master rejects it. +-- PHUnknown is the forward-compat catch-all for tags this version does not interpret. + +data BadgePresHeaderTag = PHTestTag | PHUnknownTag Char + +instance StrEncoding BadgePresHeaderTag where + strEncode = B.singleton . \case + PHTestTag -> 'T' + PHUnknownTag c -> c + strP = tag <$> A.anyChar + where + tag = \case + 'T' -> PHTestTag + c -> PHUnknownTag c + +data BadgePresHeader + = PHTest ByteString + | PHUnknown Char ByteString + +instance StrEncoding BadgePresHeader where + strEncode = \case + PHTest nonce -> strEncode PHTestTag <> nonce + PHUnknown c b -> strEncode (PHUnknownTag c) <> b + strP = + strP >>= \case + PHTestTag -> PHTest <$> A.takeByteString + PHUnknownTag c -> PHUnknown c <$> A.takeByteString + +-- v6.5.x accepts both; v7 will reject PHTest/PHUnknown +badgePresHeaderAccepted :: BadgePresHeader -> Bool +badgePresHeaderAccepted = \case + PHTest _ -> True + PHUnknown _ _ -> True + +-- Payment proof + +data BadgePurchase + = BPAppleReceipt Text + | BPGoogleReceipt Text + | BPStripeSession + | BPRedeemCode Text + deriving (Eq, Show) + +-- Master key + +newtype BadgeMasterKey = BadgeMasterKey ByteString + deriving newtype (Eq, Show, StrEncoding) + +instance ToJSON BadgeMasterKey where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON BadgeMasterKey where + parseJSON = strParseJSON "BadgeMasterKey" + +generateMasterKey :: TVar ChaChaDRG -> IO BadgeMasterKey +generateMasterKey drg = BadgeMasterKey <$> atomically (C.randomBytes 32 drg) + +-- Workflow types + +data BadgeRequest = BadgeRequest + { masterKey :: BadgeMasterKey, + badgeInfo :: BadgeInfo + } + deriving (Show) + +newtype VerifiedBadgeRequest = VerifiedBadgeRequest BadgeRequest + deriving (Show) + +-- Constants + +bbsBadgeHeader :: BBSHeader +bbsBadgeHeader = BBSHeader "SimpleX badges v1" + +bbsBadgeMessageCount :: Int +bbsBadgeMessageCount = 4 + +bbsBadgeDisclosedIndexes :: [Int] +bbsBadgeDisclosedIndexes = [1, 2, 3] + +-- Message encoding + +encodeExpiry :: Maybe UTCTime -> ByteString +encodeExpiry = maybe "lifetime" strEncode + +badgeMessages :: BadgeMasterKey -> BadgeInfo -> [ByteString] +badgeMessages (BadgeMasterKey ms) info = ms : badgeInfoMessages info + +badgeInfoMessages :: BadgeInfo -> [ByteString] +badgeInfoMessages BadgeInfo {badgeType, badgeExpiry, badgeExtra} = + [encodeExpiry badgeExpiry, encodeUtf8 (textEncode badgeType), encodeUtf8 badgeExtra] + +-- Payment verification (stub - always passes) + +verifyPayment :: BadgePurchase -> BadgeRequest -> IO (Maybe VerifiedBadgeRequest) +verifyPayment _payment req = pure $ Just (VerifiedBadgeRequest req) + +-- Server-side: issue a badge credential, recording which issuer key signed it + +issueBadge :: Int -> BBSSecretKey -> VerifiedBadgeRequest -> IO (Either String BadgeCredential) +issueBadge keyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) + | badgeExtra badgeInfo /= "" = pure $ Left "badgeExtra must be empty (reserved)" + | otherwise = fmap (\sig -> BadgeCredential keyIdx masterKey sig badgeInfo) <$> bbsSign sk bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: verify the credential received from server + +verifyCredential :: BBSPublicKey -> BadgeCredential -> IO Bool +verifyCredential pk (BadgeCredential _ masterKey signature badgeInfo) = + bbsVerify pk signature bbsBadgeHeader (badgeMessages masterKey badgeInfo) + +-- Client-side: generate a proof for a contact/group; the proof carries the credential's key index + +generateBadgeProof :: BBSPublicKey -> BadgeCredential -> BBSPresHeader -> IO (Either String BadgeProof) +generateBadgeProof pk (BadgeCredential keyIdx masterKey signature badgeInfo) ph = + fmap (\p -> BadgeProof keyIdx ph p badgeInfo) <$> bbsProofGen pk signature bbsBadgeHeader ph bbsBadgeDisclosedIndexes (badgeMessages masterKey badgeInfo) + +-- application-level proof generation with a semantic presentation header +badgeProof :: BBSPublicKey -> BadgeCredential -> BadgePresHeader -> IO (Either String BadgeProof) +badgeProof pk cred ph = generateBadgeProof pk cred (BBSPresHeader $ strEncode ph) + +-- Recipient-side: verify a badge proof with the configured key its index points to. +-- Nothing means the key index is not in the configured keys (this app version can't verify it). + +verifyBadge :: Map Int BBSPublicKey -> BadgeProof -> IO (Maybe Bool) +verifyBadge keys b@(BadgeProof keyIdx _ _ _) = case M.lookup keyIdx keys of + Nothing -> pure Nothing + Just pk -> Just <$> verifyBadgeWith pk b + +verifyBadgeWith :: BBSPublicKey -> BadgeProof -> IO Bool +verifyBadgeWith pk (BadgeProof _ ph@(BBSPresHeader phBytes) proof badgeInfo) + | either (const False) badgePresHeaderAccepted (strDecode phBytes) = + bbsProofVerify pk proof bbsBadgeHeader ph bbsBadgeDisclosedIndexes bbsBadgeMessageCount (badgeInfoMessages badgeInfo) + | otherwise = pure False + +verifyBadge_ :: Map Int BBSPublicKey -> Maybe BadgeProof -> IO (Maybe Bool) +verifyBadge_ keys = maybe (pure (Just False)) (verifyBadge keys) + +-- DB + +instance FromField BadgeType where fromField = fromTextField_ textDecode + +instance ToField BadgeType where toField = toField . textEncode + +-- (proof, pres_header, expiry, type, verified, extra, master_key, signature, key_idx) - binary columns wrapped in Binary (BLOB/bytea) +type BadgeRow = (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe UTCTime, Maybe Text, Maybe BoolInt, Maybe Text, Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int) + +-- receive/store sites have a wire proof + a computed verification outcome; +-- the status here only drives the stored verified flag, the display status is recomputed on load +badgeToRow :: Maybe BadgeProof -> Maybe Bool -> BadgeRow +badgeToRow badge verified = localBadgeToRow $ (`PeerBadge` st) <$> badge + where + st = case verified of + Just True -> BSActive + Just False -> BSFailed + Nothing -> BSUnknownKey + +localBadgeToRow :: Maybe LocalBadge -> BadgeRow +localBadgeToRow (Just lb) = case lb of + OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Just (Binary mk), Just (Binary sg), Just idx) + PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st -> + (Just (Binary p), Just (Binary ph), badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Just idx) + ShownBadge BadgeInfo {badgeType, badgeExpiry, badgeExtra} st -> + (Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Nothing) + where + verifiedField st = case st of + BSFailed -> Just (BI False) + BSUnknownKey -> Nothing + _ -> Just (BI True) +localBadgeToRow Nothing = (Nothing, Nothing, Nothing, Nothing, Just (BI False), Nothing, Nothing, Nothing, Nothing) + +rowToBadge :: UTCTime -> BadgeRow -> Maybe LocalBadge +rowToBadge now (p_, ph_, badgeExpiry, type_, verified_, extra_, mk_, sg_, idx_) = do + btText <- type_ + bt <- textDecode btText + let info = BadgeInfo {badgeType = bt, badgeExpiry, badgeExtra = maybe "" id extra_} + -- NULL badge_verified means the key index was unknown when stored (Nothing) + st = mkBadgeStatus now (unBI <$> verified_) info + case (mk_, sg_, p_, ph_, idx_) of + (Just (Binary mk), Just (Binary sg), _, _, Just idx) -> Just $ OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) info) st + (_, _, Just (Binary p), Just (Binary ph), Just idx) -> Just $ PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) info) st + _ -> Just $ ShownBadge info st + +-- JSON + +$(JQ.deriveJSON (enumJSON $ dropPrefix "BS") ''BadgeStatus) + +$(JQ.deriveJSON defaultJSON ''BadgeInfo) + +$(JQ.deriveJSON defaultJSON ''BadgeRequest) + +-- Each record is a plain JSON object (defaultJSON), platform-independent and with no credential/proof +-- tag - the context (a proof in a profile, a credential from the service) determines which it is. + +$(JQ.deriveJSON defaultJSON ''BadgeCredential) + +$(JQ.deriveJSON defaultJSON ''BadgeProof) + +-- LocalBadge is sent to the UI/clients WITHOUT crypto - only disclosed info + status. The credential/proof +-- bytes stay core-side. FromJSON reconstructs a display-only badge (empty proof) for read-only consumers +-- (remote host, UI echoes); the authoritative badge is loaded from the DB (rowToBadge), never from this JSON. +data JSONBadge = JSONBadge {badge :: BadgeInfo, status :: BadgeStatus} + +$(JQ.deriveJSON defaultJSON ''JSONBadge) + +instance ToJSON LocalBadge where + toJSON lb = toJSON $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + toEncoding lb = toEncoding $ JSONBadge (localBadgeInfo lb) (localBadgeStatus lb) + +instance FromJSON LocalBadge where + parseJSON v = do + JSONBadge info st <- parseJSON v + pure $ ShownBadge info st + +newtype BBSPublicKeyStr = BBSPublicKeyStr {toBBSPublicKey :: BBSPublicKey} + +instance IsString BBSPublicKeyStr where + fromString = BBSPublicKeyStr . fromRight (error "bad base64 in BBSPublicKey") . strDecode . B.pack diff --git a/src/Simplex/Chat/Badges/CLI.hs b/src/Simplex/Chat/Badges/CLI.hs new file mode 100644 index 0000000000..8a7cd84b61 --- /dev/null +++ b/src/Simplex/Chat/Badges/CLI.hs @@ -0,0 +1,87 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Offline operator tooling for supporter badges, invoked as `simplex-chat badge ...`. +-- keygen - the issuer keypair: the "secret" signs, the "public" goes into the app config. +-- master-key - the user's master secret (their unlinkability secret; generated client-side in the real flow). +-- sign - bind a user master secret to a badge with the issuer secret, printed as one-line JSON for `/badge add`. +module Simplex.Chat.Badges.CLI (runBadgeCommand) where + +import qualified Data.Aeson as J +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.Text as T +import Data.Time.Clock (UTCTime) +import Data.Time.Format (defaultTimeLocale, parseTimeM) +import Options.Applicative +import Simplex.Chat.Badges +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey (..), BBSSecretKey (..), bbsKeyGen) +import Simplex.Messaging.Encoding.String (strDecode, strEncode, textDecode) +import System.Exit (die) + +bbsSecretLen :: Int +bbsSecretLen = 32 + +data BadgeCommand + = Keygen + | MasterKey + | Sign Int BBSSecretKey BadgeMasterKey BadgeType (Maybe UTCTime) + +runBadgeCommand :: [String] -> IO () +runBadgeCommand args = + handleParseResult (execParserPure defaultPrefs badgeInfo args) >>= \case + Keygen -> keygen + MasterKey -> genMasterKey + Sign keyIdx sk ms badgeType badgeExpiry -> sign keyIdx sk ms badgeType badgeExpiry + where + badgeInfo = info (helper <*> hsubparser badgeCmd) fullDesc + badgeCmd = command "badge" (info (helper <*> badgeCommandP) (progDesc "SimpleX supporter badge tooling")) + +badgeCommandP :: Parser BadgeCommand +badgeCommandP = + hsubparser $ + command "keygen" (info (pure Keygen) (progDesc "generate an issuer keypair (issuer secret + public, base64url)")) + <> command "master-key" (info (pure MasterKey) (progDesc "generate a user master secret (base64url)")) + <> command "sign" (info signP (progDesc "sign a badge for a user master secret, printed as one-line JSON")) + where + signP = + Sign + <$> option auto (long "key-idx" <> metavar "KEY_IDX" <> help "index of the issuer key in the app config") + <*> option (eitherReader secretR) (long "secret" <> metavar "ISSUER_SECRET" <> help "issuer secret from keygen (base64url)") + <*> option (eitherReader (strDecode . B.pack)) (long "master" <> metavar "MASTER" <> help "user master secret from master-key (base64url)") + <*> option (eitherReader badgeTypeR) (long "type" <> metavar "TYPE" <> help "badge type (supporter, legend, investor)") + <*> option (eitherReader expireR) (long "expire" <> metavar "lifetime|YYYY-MM-DD" <> help "expiry date, or 'lifetime'") + secretR s = do + sk@(BBSSecretKey b) <- strDecode (B.pack s) + if B.length b == bbsSecretLen + then Right sk + else Left "bad issuer secret - use the 'secret' value from keygen" + badgeTypeR = maybe (Left "invalid badge type") Right . textDecode . T.pack + expireR = \case + "lifetime" -> Right Nothing + s -> maybe (Left "use 'lifetime' or YYYY-MM-DD") (Right . Just) $ parseTimeM True defaultTimeLocale "%Y-%m-%d" s + +keygen :: IO () +keygen = + bbsKeyGen >>= \case + Left e -> die $ "keygen failed: " <> e + Right (BBSPublicKey pk, BBSSecretKey sk) -> do + B.putStrLn $ "secret " <> strEncode sk + B.putStrLn $ "public " <> strEncode pk + +genMasterKey :: IO () +genMasterKey = do + drg <- C.newRandom + mk <- generateMasterKey drg + B.putStrLn $ strEncode mk + +sign :: Int -> BBSSecretKey -> BadgeMasterKey -> BadgeType -> Maybe UTCTime -> IO () +sign keyIdx secretKey masterKey badgeType badgeExpiry = do + let req = VerifiedBadgeRequest (BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}} :: BadgeRequest) + issueBadge keyIdx secretKey req >>= \case + Left e -> die $ "sign failed: " <> e + -- single-line JSON (master secret + signature + info), pasted into the app via `/badge add` + Right cred -> LB.putStrLn $ J.encode cred diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c92c1f9e09..fbb8536fcf 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -81,6 +81,8 @@ import Simplex.Messaging.Agent.Store.DB (SQLError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges (BadgeCredential) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption) @@ -137,6 +139,8 @@ coreVersionInfo simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, + -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation + badgePublicKeys :: Map Int BBSPublicKey, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -172,7 +176,7 @@ data ChatConfig = ChatConfig -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. mkStoreCxt :: ChatConfig -> StoreCxt -mkStoreCxt ChatConfig {chatVRange} = StoreCxt chatVRange +mkStoreCxt ChatConfig {chatVRange, badgePublicKeys} = StoreCxt chatVRange badgePublicKeys {-# INLINE mkStoreCxt #-} data RandomAgentServers = RandomAgentServers @@ -575,6 +579,7 @@ data ChatCommand | SetBotCommands [ChatBotCommand] | UpdateProfile ContactName (Maybe Text) -- UserId (not used in UI) | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) + | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 3c1ce9bc26..e51f7a40e8 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -138,7 +138,7 @@ createActiveUser cc CoreChatOpts {chatRelay} = \case loop = do displayName <- T.pack <$> withPrompt "display name: " getLine createUser loop $ mkProfile displayName - mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} createUser onError p = execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = chatRelay}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f35a9ef177..43f480b5c4 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -55,6 +55,7 @@ import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber +import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -363,16 +364,16 @@ processChatCommand cxt nm = \case user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) p userChatRelay True ts mapM_ (setUserServers db user ts) uss - createPresetContactCards db user `catchAllErrors` \_ -> pure () + createPresetContactCards db cxt user `catchAllErrors` \_ -> pure () createNoteFolder db user pure user atomically . writeTVar u $ Just user pure $ CRActiveUser user where - createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () - createPresetContactCards db user = do - createContact db user simplexStatusContactProfile - createContact db user simplexTeamContactProfile + createPresetContactCards :: DB.Connection -> StoreCxt -> User -> ExceptT StoreError IO () + createPresetContactCards db cxt user = do + createContact db cxt user simplexStatusContactProfile + createContact db cxt user simplexTeamContactProfile chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) chooseServers user_ = do as <- asks randomAgentServers @@ -1941,7 +1942,8 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - let userData = contactShortLinkData (userProfileDirect user incognitoProfile Nothing True) Nothing + linkProfile <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True + let userData = contactShortLinkData linkProfile Nothing userLinkData = UserInvLinkData userData -- TODO [certs rcv] (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKPQOn subMode @@ -1963,7 +1965,7 @@ processChatCommand cxt nm = \case updatePCCIncognito db user conn (Just pId) sLnk pure $ CRConnectionIncognitoUpdated user conn' (Just incognitoProfile) (ConnNew, Just pId, False) -> do - sLnk <- updatePCCShortLinkData conn $ userProfileDirect user Nothing Nothing True + sLnk <- updatePCCShortLinkData conn =<< presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) conn' <- withFastStore' $ \db -> do deletePCCIncognitoProfile db user pId updatePCCIncognito db user conn Nothing sLnk @@ -1982,9 +1984,10 @@ processChatCommand cxt nm = \case recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode let short = isJust $ connShortLink' =<< connLinkInv - userLinkData_ - | short = Just $ UserInvLinkData $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing - | otherwise = Nothing + userLinkData_ <- + if short + then Just . UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser Nothing (userProfileDirect newUser Nothing Nothing True) + else pure Nothing -- TODO [certs rcv] (agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink @@ -2259,10 +2262,11 @@ processChatCommand cxt nm = \case Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode -- TODO [relays] relay: add identity, key to link data? - let userData - | isTrue userChatRelay = relayShortLinkData (userProfileDirect user Nothing Nothing True) - | otherwise = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing - userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} + userData <- + if isTrue userChatRelay + then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) + else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) + let userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} -- TODO [certs rcv] (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink @@ -3143,7 +3147,7 @@ processChatCommand cxt nm = \case joinPreparedConn subMode conn joinPreparedConn subMode conn = do -- [incognito] send membership incognito profile - let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile gInfo) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True dm <- encodeConnInfo $ XInfo p (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3293,6 +3297,7 @@ processChatCommand cxt nm = \case fileStatus <- withFastStore $ \db -> getFileTransferProgress db user fileId pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) + AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -3520,7 +3525,7 @@ processChatCommand cxt nm = \case conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' joinPreparedConn conn incognitoProfile chatV joinPreparedConn conn incognitoProfile chatV = do - let profileToSend = userProfileDirect user incognitoProfile Nothing True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3624,7 +3629,7 @@ processChatCommand cxt nm = \case relayLinkData_ <- liftIO $ decodeLinkUserData cData case (relayLinkData_, linkEntityId) of (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p + withFastStore $ \db -> updateRelayMemberData db cxt user relayMember (MemberId entityId) (MemberKey relayKey) p _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) @@ -3667,11 +3672,12 @@ processChatCommand cxt nm = \case joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile - let profileToSend = case gInfo_ of - Just gInfo_' -> - let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' - in userProfileInGroup' user allowSimplexLinks incognitoProfile - Nothing -> userProfileDirect user incognitoProfile Nothing True + profileToSend <- + presentUserBadge user incognitoProfile $ case gInfo_ of + Just gInfo_' -> + let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' + in userProfileInGroup' user allowSimplexLinks incognitoProfile + Nothing -> userProfileDirect user incognitoProfile Nothing True chatEvent <- case gInfo_ of Just (Just gInfo) | useRelays' gInfo -> do let GroupInfo {membership = GroupMember {memberId}} = gInfo @@ -3688,12 +3694,12 @@ processChatCommand cxt nm = \case contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRejected && s /= GSMemRemoved && s /= GSMemLeft - checkSndFile :: CryptoFile -> CM Integer - checkSndFile (CryptoFile f cfArgs) = do + checkSndFile :: Maybe LocalBadge -> CryptoFile -> CM Integer + checkSndFile sndBadge (CryptoFile f cfArgs) = do fsFilePath <- lift $ toFSFilePath f unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs - when (fromInteger fileSize > maxFileSize) $ throwChatError $ CEFileSize f + when (fromInteger fileSize > maxXFTPFileSize sndBadge) $ throwChatError $ CEFileSize f pure fileSize updateProfile :: User -> Profile -> CM ChatResponse updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p' @@ -3723,7 +3729,7 @@ processChatCommand cxt nm = \case case changedCts_ of Nothing -> pure $ UserProfileUpdateSummary 0 0 [] Just changedCts -> do - let idsEvts = L.map ctSndEvent changedCts + idsEvts <- mapM ctSndEvent changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ unless (null errs) $ toView $ CEvtChatErrors errs @@ -3747,8 +3753,11 @@ processChatCommand cxt nm = \case mergedProfile = userProfileDirect user Nothing (Just ct) False ct' = updateMergedPreferences user' ct mergedProfile' = userProfileDirect user' Nothing (Just ct') False - ctSndEvent :: ChangedProfileContact -> (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) - ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = (ConnectionId connId, Nothing, XInfo mergedProfile') + -- non-incognito (filtered above), so the user's badge is presented; a profile update keeps the badge instead of clearing it + ctSndEvent :: ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) + ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = do + p <- presentUserBadge user' Nothing mergedProfile' + pure (ConnectionId connId, Nothing, XInfo p) ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> @@ -3756,9 +3765,9 @@ processChatCommand cxt nm = \case setMyAddressData :: User -> UserContactLink -> CM UserContactLink setMyAddressData user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_, addressSettings} = do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user - let shortLinkProfile = userProfileDirect user Nothing Nothing True - -- TODO [short links] do not save address to server if data did not change, spinners, error handling - userData + shortLinkProfile <- presentUserBadge user Nothing $ userProfileDirect user Nothing Nothing True + -- TODO [short links] do not save address to server if data did not change, spinners, error handling + let userData | isTrue userChatRelay = relayShortLinkData shortLinkProfile | otherwise = contactShortLinkData shortLinkProfile $ Just addressSettings userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} @@ -3779,7 +3788,8 @@ processChatCommand cxt nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchAllErrors` eToView + p <- presentUserBadge user incognitoProfile mergedProfile' + void (sendDirectContactMessage user ct' $ XInfo p) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> CM ChatResponse @@ -4065,7 +4075,7 @@ processChatCommand cxt nm = \case Just r -> pure r Nothing -> do (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ov = verifyLinkOwner rootKey [] l sig_ invitationReqAndPlan cReq (Just l') contactSLinkData_ ov where @@ -4092,7 +4102,7 @@ processChatCommand cxt nm = \case withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do - contactSLinkData_ <- liftIO $ decodeLinkUserData cData + contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) let ContactLinkData _ UserContactData {owners} = cData ov = verifyLinkOwner rootKey owners l' sig_ plan <- contactRequestPlan user cReq contactSLinkData_ ov @@ -4261,7 +4271,7 @@ processChatCommand cxt nm = \case contactShortLinkData p settings = let msg = autoReply =<< settings business = maybe False businessAddress settings - contactData = ContactShortLinkData p msg business + contactData = ContactShortLinkData p msg business Nothing in encodeShortLinkData contactData relayShortLinkData :: Profile -> UserLinkData relayShortLinkData Profile {displayName, fullName, shortDescr, image} = @@ -4325,7 +4335,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if contactConnIncognito ct then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 $ CGContact ct pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4406,7 +4417,8 @@ processChatCommand cxt nm = \case setupSndFileTransfers n = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do - fileSize <- checkSndFile file + let User {profile = LocalProfile {localBadge}} = user + fileSize <- checkSndFile (if incognitoMembership gInfo then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) @@ -4641,6 +4653,28 @@ createContactsSndFeatureItems user cts = CUPContact {preference} -> preference CUPUser {preference} -> preference +-- attach an issued badge credential to the user's own profile and present it to all current contacts. +-- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). +addUserBadge :: User -> BadgeCredential -> CM () +addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do + keys <- asks $ badgePublicKeys . config + key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys + verified <- liftIO $ verifyCredential key cred + unless verified $ throwCmdError "badge credential does not verify against configured key" + now <- liftIO getCurrentTime + user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + asks currentUser >>= atomically . (`writeTVar` Just user') + cxt <- asks $ mkStoreCxt . config + contacts <- withFastStore' $ \db -> getUserContacts db cxt user' + withChatLock "addUserBadge" $ forM_ contacts $ \ct -> + case contactSendConn_ ct of + Right conn + | not (connIncognito conn) -> do + let ct' = updateMergedPreferences user' ct + p <- presentUserBadge user' Nothing $ userProfileDirect user' Nothing (Just ct') False + void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView + _ -> pure () + assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -5241,6 +5275,7 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNameDescr), ("/profile" <|> "/p") $> ShowProfile, + "/badge add " *> (AddBadge <$> jsonP), "/set bot commands " *> (SetBotCommands <$> botCommandsP), "/delete bot commands" $> SetBotCommands [], "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), @@ -5378,7 +5413,7 @@ chatCommandP = quoted = A.char '\'' *> A.takeTill (== '\'') <* A.char '\'' newUserP userChatRelay = do (cName, shortDescr) <- profileNameDescr - let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space @@ -5386,7 +5421,7 @@ chatCommandP = let preferences = case files_ of Just True -> Nothing _ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}} - profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences} + profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing} pure NewUser {profile, pastTimestamp = False, userChatRelay = False} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f2c448d5b8..68e870a7c5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -53,6 +53,7 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds, secondsToDiffTime) +import Simplex.Chat.Badges (BadgeCredential (..), BadgePresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files @@ -906,7 +907,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId Just conn@Connection {customUserProfileId} -> do incognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId pure (ct, conn, ExistingIncognito <$> incognitoProfile) - let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True dm <- encodeConnInfoPQ pqSup' chatV $ XInfo profileToSend -- TODO [certs rcv] (ct,conn,) . fst <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode) @@ -919,7 +920,7 @@ acceptContactRequestAsync UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId, pqSupport = cReqPQSup} incognitoProfile = do subMode <- chatReadVar subscriptionMode - let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV @@ -947,8 +948,9 @@ acceptGroupJoinRequestAsync memberKey_ = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted + cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -964,7 +966,6 @@ acceptGroupJoinRequestAsync groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -982,8 +983,9 @@ acceptGroupJoinSendRejectAsync cReqXContactId_ rejectionReason = do gVar <- asks random + cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing let GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkReject $ @@ -994,7 +996,6 @@ acceptGroupJoinSendRejectAsync rejectionReason } subMode <- chatReadVar subscriptionMode - cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV withStore $ \db -> do @@ -1197,8 +1198,8 @@ memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, a allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g redactedMemberProfile :: Bool -> Profile -> Profile -redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType} = - Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType} +redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType, badge} = + Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType, badge} where removeSimplexLink s | allowSimplexLinks = Just s @@ -1895,6 +1896,33 @@ sendDirectContactMessages' user ct events = do forM_ pqEnc_ $ \pqEnc' -> void $ createContactPQSndItem user ct conn pqEnc' pure sndMsgs' +-- present the user's own badge on an outgoing profile: a fresh, single-use proof from the stored credential. +-- the send's incognito profile (when set) suppresses it - an incognito identity must never carry the badge. +-- a long-expired badge is not presented at all (receivers would hide it anyway). +presentUserBadge :: User -> Maybe i -> Profile -> CM Profile +presentUserBadge User {profile = LocalProfile {localBadge}} incognitoProfile p = case (incognitoProfile, localBadge) of + (Nothing, Just (OwnBadge cred@(BadgeCredential keyIdx _ _ _) st)) | st == BSActive || st == BSExpired -> do + keys <- asks $ badgePublicKeys . config + case M.lookup keyIdx keys of + Nothing -> p <$ logError "presentUserBadge: badge key index not in config" + Just key -> do + nonce <- drgRandomBytes 16 + liftIO (badgeProof key cred (PHTest nonce)) >>= \case + Right proof -> pure p {badge = Just proof} + Left e -> p <$ logError ("presentUserBadge: proof generation failed: " <> T.pack e) + _ -> pure p + +-- receiving side of contact/invitation link data: verify the badge proof from the link profile +-- and set the crypto-free display badge for the UI (the raw proof stays in profile for APIPrepareContact) +linkDataBadge :: ContactShortLinkData -> CM ContactShortLinkData +linkDataBadge cld@ContactShortLinkData {profile = Profile {badge}} = case badge of + Nothing -> pure cld + Just b@(BadgeProof _ _ _ info) -> do + keys <- asks $ badgePublicKeys . config + verified <- liftIO $ verifyBadge keys b + now <- liftIO getCurrentTime + pure (cld :: ContactShortLinkData) {localBadge = Just $ ShownBadge info (mkBadgeStatus now verified info)} + sendDirectContactMessage :: MsgEncodingI e => User -> Contact -> ChatMsgEvent e -> CM (SndMessage, Int64) sendDirectContactMessage user ct chatMsgEvent = do conn@Connection {connId} <- liftEither $ contactSendConn_ ct @@ -2102,8 +2130,9 @@ sendGroupMessages user gInfo scope asGroup members events = do sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - profileUpdateEvent = XInfo $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p - void $ sendGroupMessage' user gInfo members' profileUpdateEvent + -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented + profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p + void $ sendGroupMessage' user gInfo members' $ XInfo profileUpdate currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -2837,7 +2866,8 @@ simplexTeamContactProfile = image = Just simplexChatImage, contactLink = Just $ CLFull adminContactReq, peerType = Nothing, - preferences = Nothing + preferences = Nothing, + badge = Nothing } simplexStatusContactProfile :: Profile @@ -2849,7 +2879,8 @@ simplexStatusContactProfile = image = Just (ImageData "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAr6ADAAQAAAABAAAArwAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgArwCvAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQAC//aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Q/v4ooooAKKKKACiiigAoorE8R+ItF8J6Jc+IvEVwlrZ2iGSWWQ4CgVUISlJRirtmdatTo05VaslGMU223ZJLVtvokbdFfl3of/BRbS734rtpup2Ig8LSsIYrjnzkOcea3bafTqBX6cafqFjq1jFqemSrPbzqHjkQ5VlPIINetm2Q43LXD65T5eZXX+XquqPiuC/Efh/itYh5HiVUdGTjJWaflJJ6uEvsy2fqXKKKK8c+5Ciq17e2mnWkl/fyLDDCpd3c4VVHJJJr8c/2kf8Ago34q8M3mpTfByG3fT7CGSJZrlC3nStwJF5GFU8gd69LA5VicXTrVaMfdpxcpPokk397toj4LjvxKyLhGjRqZxValVkowhFc05O9m0tPdjfV7dN2kfq346+J3w9+GWlPrXxA1m00i1QZL3Uqxj8Mnn8K/Mj4tf8ABYD4DeEJ5dM+Gmn3niq4TIE0YEFtn/ffBI+imv51vHfxA8b/ABR1+bxT8RNUuNXvp3LtJcOWCk84VeigdgBXI18LXzupLSkrL72fzrxH9IXNsTKVPKKMaMOkpe/P8fdXpaXqfqvrf/BYH9p6+1w3+iafo1jZA8WrRPKSPeTcpz9BX1l8J/8Ags34PvxDp/xn8M3OmSnAe709hcQfUoSHA/A1/PtSE4/GuKGZ4mLvz39T4TL/ABe4swlZ1ljpTvvGaUo/dbT/ALdsf2rfCX9pT4HfHGzF18M/EdnqTYBaFXCzJn+9G2GH5V7nX8IOm6hqGkX8eraLcy2d3EcpPbuY5FPsykGv6gf+CWf7QPxB+OPwX1Ky+JF22pX3h69+yJdyf62WJlDrvPdlzjPevdwGae3l7OcbP8D+i/DTxm/1ixkcqx2H5K7TalF3jLlV2rPWLtqtWvM/T2iiivYP3c//0f7+KKKKACiiigAooooAK/Fv/goX8Qvi2fFcXgfWrRtP8NDEls0bZS7YfxORxlT0Xt1r9pK8u+L/AMI/Cfxp8F3HgvxbFujlGYpgB5kMg6Op9R+tfR8K5vQy3MYYnE01KK0843+0vNf8NZn5f4wcFZhxTwziMpy3FOjVeqSdo1Lf8u5u11GXk97Xuro/mBFyDX3t+yL+2Be/CW+h8B+OHafw7cyALIxJa0Ldx6p6jt1FfMvx/wDgR4w/Z+8YN4d8RoZrSbLWd4owk6D+TDuK8KF0K/pLFYHA51geWVp0pq6a/Brs1/wH2P8ALvJsz4h4D4h9tR5qGLoS5ZRls11jJbSjJferSi9mf1uafqFlqtlFqWmyrPBOoeORDlWU8gg069vrPTbSS/v5FhghUu7ucKqjqSa/CH9j79sm++EuoQ/D/wAeSNceHbmRVjlZstZk9x6p6jt2q3+15+2fffFS8n8AfD2V7bw9CxWWZThrwj+Se3evxB+G2Zf2n9TX8Lf2nTl/+S/u/PbU/v2P0nuGv9Vf7cf+9/D9Xv73tLd/+ffXn7afF7pqftbfth3nxUu5vAXgGR7fw/A5WWUHDXZX19E9B361+Z/xKm3eCL9R3UfzFbQul6Cn+I/A3ivxR8LPEXivSbVn07RoVkurg8Iu5gAue7HPSv1HOsrwmVcN4uhRSjBUp6vq3Fq7fVt/5I/gTNeI884x4kjmeYOVWtKSdop2hCPvWjFbQjFNv5ybbuz4Toqa0ge9uoLOIhWnkSNSxwAXIUEnsBnmv0+/aK/4Jg+O/gj8Hoviz4b1n/hJFt40l1G2ig2NDG4yZEIJ3KvfgHHNfxVTw9SpGUoK6W5+xZVw1mWZYfEYrA0XOFBKU2raJ31te72b0T0R+XRIAyegr+gr/glx+yZoHhjwBc/tKfFywiafUY2OmpeIGS3sVGWmIbgF+TkjhR71+YP7DX7Lt9+1H8ZLfR75WTw5pBS61ScDKsoIKwg+snf0Ffqd/wAFSv2o4Phf4Ltv2WvhmVtrjUbRBfvA2Ps1kOFhAHQyAc9ML9a9HL6UacHi6q0W3mz9Q8M8owuV4KvxpnEL0aN40Yv/AJeVXpp5LZPo7v7J+M/7U/jX4e/EL4/+JfFXwrsI9P0Ke5K26RKESTZw0oUcAOeQBX7J/wDBFU5+HPjYf9RWH/0SK/nqACgKOgr+hT/giouPh143b11SH/0SKWVzc8YpPrf8jHwexk8XxzSxVRJSn7WTSVknKMnoui7H7a0UUV9cf3Mf/9L+/iiiigAoorzX4wfGD4afAP4bav8AF74v6xbaD4d0K3e6vb26cJHHGgyevUnoAOSeBTjFyajFXYHpVFf55Xxt/wCDu34nj9vzS/G3wX0Qz/ArQ2ksLnSp1CXurQyMA15uPMTqBmJD2+914/uU/Y//AGxfgH+3P8ENL+P37OutxazoWpoNwHyzW02PmhmjPKSKeCD9RxXqY/JcXg4QqV4WUvw8n2ZnCrGTaTPqGiiivKNDy/4u/CLwd8afBtx4N8ZW4kilBMUoH7yGTs6HsR+tfzjftA/AXxl+z54yfw34jQzWkuXs7xF/dzR/0YdxX9OPiDxBofhPQ7vxN4mu4rDT7CF57m4ncJHFFGMszMcAAAZJNf53n/Bav/g5W1H4ufGjTvg5+xB5F14E8JX4l1HVriIE6xNE2GjhLDKQdRuGC55HHX9L8Os+x2ExP1eKcsO/iX8vmvPy6/ifg3jZ4NYDjDBPFUEqeYU17k/50vsT8n0lvF+V0fq0LhTUgnA4r4y/ZG/bJ+FX7YXw9HjDwBP5N/ahV1LTZeJrSUjoR3U/wsOK+sRdL/n/APXX9G0nCrBTpu6Z/mVmuSYvLcXUwOPpOnWg7SjJWaf9ap7NarQ+pf2dP2evGH7Q3i4aLogNvp1uQ15esMpEnoPVj2Ffrd+1V8GvDnw5/YU8X+APh/Z7IrewEjYGXlZGUs7nqSQM18C/sO/ti6b8F7o/Dnx6qpoN9LvS6RRvglbjL45ZT69vpX7wX1poHjjwxNYzbL3TdUt2jbaQySRSrg4PoQa/nnxXxGaTxLwmIjy4e3uW2lpu33Xbp87v+7Po58I8L4nhfFVMuqKeY1oTp1nJe9S5k0oxWtoPfmXxve1uVfwqKA0YHYiv6Ev+CZ37bVv490eP9mb4zXAn1GKJo9Murg5F3bgYMLk9XUcD+8tflR+1/wDsn+Nv2XfiNdadqFs8vh28md9Mv1GY3iJyEY9nXoQa+UrC/v8ASr+DVdJnktbq2dZYZomKvG6nIZSOhFfztQrVMJW1Xqu5+Z8PZ5mvBWeSc4NSg+WrTeinHqv1jL56ptP+s7xHZ/A//gnR8EfE/jTwra+RHqF5JdxWpbLTXcwwkSnrsGPwXNfyrfEDx54l+J/jXU/iB4wna51LVZ3nmdj3Y8KPQKOAPQV2vxX/AGhvjT8corC3+K2vz6vFpq7beNgERT3YqvBY92NeNVeOxirNRpq0Fsju8RePKWfTo4TLqPscFRXuU9F7z+KTSuvJK7srvqwr+ir/AIIuaVd2/wAH/FesSIRDd6uFjb+8Y41Dfka/BX4YfCzx78ZfGVr4C+G+nyajqV22Aqj5I17u7dFUdya/r+/ZV+Aenfs2fBLSPhbZyC4ntVaW7nAx5tzKd0jfTJwPYV1ZLQk63tbaI+w8AOHcXiM8ebcjVClGS5ujlJWUV3sm27baX3R9FUUUV9Uf2gf/0/7+KKKKACv4If8Ag8QT9vN9W8IsVk/4Z+WJedOL7f7Xyd32/HGNu3yc/LnPev73q84+Lnwj+G/x3+HGr/CT4uaRba74d123e1vbK6QPHJG4weD0I6gjkHkV6WUY9YLFQxDgpJdP8vMipDmi0f4W1frt/wAEhP8Agrt8af8AglD8b38V+Fo21zwPr7xp4i0B3KpcRoeJoTyEnjBO04+boeK+m/8AguZ/wQz+I3/BMD4kyfEn4Ww3fiD4Oa5KzWWolC76XKx4tbphwOuI3PDAc81/PdX7LCeFzHC3VpU5f18mjympU5eZ/t9fsk/tb/Av9tv4G6N+0F+z3rUWs6BrEQYFCPNt5cfPDMnVJEPDKf5V794h8Q6F4T0O78TeJ7uGw06wiae4uZ3EcUUaDLMzHAAA6k1/j9f8EiP+Cunxv/4JTfHAeKPCZfWfAuuyRx+IvD8jkRTxg486Lsk8YJ2n+Loa/V7/AILy/wDBxZd/t2eHl/Zc/Y6mu9I+Gl1DDNrWoSBoLvUpGAY2+OqQoeH/AL5GOlfneI4OxCxio0taT+12Xn59u53xxMeW73ND/g4M/wCDgzVP2yNV1H9jz9j3UZrD4ZWE7waxrEDlH110ONiEYItgQe/7z6V/I6AAMDgCgAKNo6Cv0j/4Jkf8Ex/j/wD8FOvj/Y/Cj4UWE9voFvNGdf18xk2um2pPzEt0MhGdiZyTX6FhsNhctwvLH3YR1bfXzfn/AEjhlKVSR77/AMEMf2Rf2v8A9qr9tPRrb9mNpdL0fSp438UaxKjNYW+nk/PHKOA7uoIjTrnniv7Lfj98CvG37PPjiXwj4uiLxNl7S7UYjuIuzD39R1Ffvt+wn+wd+z5/wTy+A+n/AAF/Z70pbKyt1V728cA3V/c4w0079WYnoOijgV7V8cPgb4G+Pngqfwb41twwYEwXCgebBJ2ZT/MdDXi5N4mTwmYWqRvhXpb7S/vL9V28z8c8YfBXC8XYL61hbQx9Ne7LpNfyT8v5ZfZfkfyXi5r9Lf2Jv24bn4S3UHwz+JkzT+HZ5AsNy5LNZlu3vHn8q+KPj38CPHf7PPjabwn4yt2ELMxtLsD91cRg8Mp6Z9R2rxAXAPANfuePyzL89y/2c7TpTV1JdOzT6Nf8Bn8C5FnGfcEZ79Yw96OJpPlnCS0a6xkusX/k4u9mf2IeK/B/w++Mngt9C8U2ltrWi6lEGCuA6OrDhlPY+hHNfztftw/8E4tN+AGlTfE34ba3HJo0koVdMvGC3CFv4Ym/5aAenBArvf2PP2+9R+CGmv4B+JSy6joEUbtaOp3TQOBkRj1Rjx7V8uftEftH+Nf2i/G7+KPEzmG0hyllZqT5cEef1Y9zX4LT8GMTisynhsY7UI6qot5J7Jefe+i87o/prxI8YuEM/wCF6WM+rc2ZSXKo6qVJrdykvih/Ktebsmnb4DkilicxyqVYdQRzXUaN4R1HVMSzjyIf7zDk/QV6dIlpJIJ5Y1Z16MRk1+qf7DX7Ed58ULmH4p/Fe2kt/D8Dq9paSDabwjncf+mf/oX0rKXg3lOR+0zDPMW6lCL92EVyufZN3vfyjbvdI/AeFsJnHFOPp5TktD97L4pP4YLrJu2iXnq3ok20es/8Erv2f/G/gf8AtD4ozj7Bo2pwiFIpY/3t2VOQ4J5VFzx659q/aKq9paWthax2VlGsUMShERBtVVHAAA6AVYr4LNcdTxWIdSjRjSpqyjGKslFber7t6tn+k3APB1LhjJaOUUqsqjjdylJ/FKTvJpfZV9orbzd2yiiivNPsj//U/v4ooooAKKKKAPO/iz8Jvh18c/h1q/wm+LGk2+ueHtdt3tb2yukDxyxuMEEHoR1B6g81/lm/8Fy/+CFfxG/4Jh/ENvid8J4bzxF8Htdmke1vliaRtHctxbXTAEBecRyHAbGDzX+q54j8R6B4Q0C88U+KbyHT9N0+F7i5ubhxHFFFGMszMcAADqa/zM/+Dhb/AIL06p+3f4rvP2Tf2Xr6S0+Eui3DR397GcHXriM8N7W6EfIP4jz6V9fwfPGLFctD+H9q+3/D9jmxKjy+9ufyq0UAY4or9ZPMP0v/AOCX3/BLf9oT/gqP8d4Phf8ACa0lsvDtjLG3iDxDJGTa6bbse56NKwB8uPOSfav9ZX9hD9hT4Df8E8v2fdK/Z7+AenLbWNkoe8vHUfab+6I+eeZhyWY9B0UcCv8AKC/4JUf8FV/j1/wSu+PCfEf4aSHUvC+rPHH4i0CViIL63U43D+7MgJKN+B4r/Wd/Yy/bM+BH7eHwH0j9oL9n7Vo9S0fU4182LI8+0nx88MydVdTxz16ivzbjZ43nipfwOlu/n59uh6GE5Labn1ZRRRXwB2Hi3x3+BPgj9oHwJceCPGcIIYFre4UfvYJezKf5jvX8vH7QvwB8d/s4eOZfB/jKEtDIS9neKP3VxFngqfX1Hav6gvj58e/An7PHgK48ceN7gLtBW2twf3txL2RR/M9hX8rX7Qn7Rnjz9o3x5L418ZyhUXKWlqh/dW8WeFUevqe5r988G4Zu3Ut/ueu/839z/wBu6fM/jj6UdPhlwo8y/wCFTS3Lb+H/ANPf/bPtf9unlQuAec077SPWueFznrTxc1+/eyP4udE/XX9g79h24+K8tv8AF74qQvD4fgkDWdo64N4V53H/AKZg/wDfX0r+ge0tLWwtY7KyjWKGJQiIgwqqOAAOwFfzc/sIft2XnwO1KH4ZfEeVp/Ct5L8k7Es9k7YHH/TMnkjt1r+kDTNT07WtOg1fSJ0ubW5QSRSxncjowyCCOoNfyr4q0s3jmreYfwtfZW+Hl/8Akv5r6/Kx/or9HSXDX+rqhkqtidPb81vac/d/3P5Lab/auXqKKK/Lz+gwooooA//V/v4ooooAKxfEniTQPB2gXnirxVew6dpunQvcXV1cOI4oYoxlndjgAADJJrar/PV/4Ozf+CiX7Xlr8Yrf9hCx0u98GfDaS0iv5L1GZT4iZs5HmKceTERgx9d3LcYr08py2eOxMaEXbu/L9SKk1CN2fIX/AAcD/wDBfrXv27vFF1+yx+ylqFzpnwl0id476+icxSa/MhwGOMEWykHYv8fU9hX8qoAAwOAKUAAYFfqj/wAEnf8AglH8cv8Agqp8ek+Hvw/R9M8I6NJFJ4k19lzHZW7k/ImeGmcAhF/E8V+xUKGFyzC2Xuwju/1fds8tuVSXmM/4JQ/8Epfjr/wVU+Pcfw5+HiPpXhPSXjl8ReIZEJhsoGP3E7PO4B2J+J4r7o/4Li/8EC/H3/BL/UYPjH8Hp7vxV8JNQMcL3sy7rnTLkgDbcFRjZI3KPwATg9q/0rP2MP2MPgL+wZ8BdI/Z5/Z60hNM0bS4x5kpANxeTn7887gAvI55JPToOK9y+J/ww8AfGfwBqvwu+KOlW+t6Brdu9re2V0gkilicYIIP6HqDXwVbjSu8YqlNfulpy9139e3Y7VhY8tnuf4VdfqD/AMErP+Cpvx1/4Jb/ALQNn8S/h7cS6j4VvpUj8QeH2kIt723zgsB0WVRyjetffn/BeH/ghJ4x/wCCZvjlvjP8EYbvXPg5rk7GKcqZJdGmc5FvOwH+rOcRyH0wea/nCr9ApVcNmOGuvehL+vk0cLUqcvM/24v2Mf20PgH+3l8CdK/aA/Z61iPVNI1FF86LI+0Wc+PnhnTqjqeOevUcV3nx/wD2gfh/+zp4CuPHHjq5CBQVtrZT+9uJeyIP5noBX+Ud/wAEL/25f2t/2NP2u7A/s7xPrPhzW5Yk8T6LOzCyls1PzTE9I5UXJRupPHIr+p39o79pXx/+0v8AEGbxv42l2RrlLO0QnyreLPCqPX1PUmvM4b8KauYZg5VJWwkdW/tP+6vPu+i8z8r8VvF3D8L4P6vhbTx017sekF/PL/21fafkjV/aF/aN8e/tHePZ/GvjOc+XuK2lopPlW8WeFUevqe9eFfasDmsL7UB1r9kv+Cen/BPuX4mPa/Gv41Wrw6HE4k0/T5FwbsjkO4PPl56D+L6V/QWbZjlnDmW+1q2hSgrRit2+kYrq/wDh2fw9kXDmdcZ526NK9SvUfNOctorrKT6JdF6JIh/Yq/4JyXXxq8MSfEn4wtPpukXkLLp1vH8s0hYcTHPRR1Ud6+KP2nP2bvHX7MXj+Twl4pUz2U+Xsb5QRHcRZ/Rh/Etf2D2trbWNtHZ2caxRRKEREGFVRwAAOgFeSfHL4G+Af2gvAVz4A8f2wmt5huimUDzYJB0dD2I/Wv5/yrxgx0c3niMcr4abtyL7C6OPdrr/ADeWlv604g+jdlFTh6ngsrfLjaauqj/5eS6xn2i/s2+Hz1v/ABi+d3r9O/2DP28r/wCBGpRfDT4lSvdeFL2UBJmYs9izcZX1j7kduor48/ah/Zr8bfsu/EWTwZ4pHn2c4MtheqMJcQ5IB9mHRhXzd9oAFf0Djsuy3iHLeSdqlGorpr8Gn0a/4DW6P5DyrMc74Mzz2tG9LE0XaUXs11jJdYv/ACaezP7pdK1bTNd02DWdGnS6tLlBJFLEwZHRuQQR1FaFfix/wSG1n47X3hPVLHXUL+BoT/oEtxneLjPzLD6pjr2B6d6/aev424nyP+yMyrZf7RT5Huvv17NdV0Z/pTwPxP8A6w5Lh82dGVJ1FrGXdaNp9YveL6oKKKK8A+sP/9b+/iiiigAr4E/4KI/8E4f2b/8AgpZ8DLr4M/H7SklljV5NJ1aJQLzTblhxLC/Uc43L0YcGvvuitKNadKaqU3aS2Ymk1Zn+Vt8Nf+DZH9vDxJ/wUEn/AGQfGti+m+DdMkF5eeNlTNjLpRb5Xgz964cfL5XVWyTx1/0lv2L/ANif9nv9gn4H6b8Bv2dNDh0jSrFF8+YKDcXs4GGmuJOskjHPJ6dBxX1lgZz3pa9bNc+xWPjGFV2iui6vu/60M6dKMNgooorxTU4T4m/DHwB8ZfAeqfDH4paRba7oGtQPbXtjeRiWGaJxghlII/wr/M//AOCw/wDwbq/En9kb9o7Ttc/ZhQ6h8KvGl4VgknkUyaJIxy0UmTueMDmNgCexr/SN/aA/aA+Hf7N3w6u/iL8RbtYYIFIggBHm3Ev8Mca9yfyA5NfyB/tTftZfEX9qv4gSeL/GEv2exgLJYWEZPlW8WeOO7H+Ju9fsXhRwnmOZYl4hNwwi+Jv7T/lj5930Xnofj3iv4nYThrCPD0bTxs17kekV/PPy7L7T8rn58fs1fs1/Df8AZg8Dp4U8CwB7qYK19fuAZrmQDkseyjsvQV9GfaWrAWcjvUnnt6mv62w+Cp0KapUo2itkfwFmOLxWPxNTGYyo51Zu8pN6t/1stktEftx/wTa/YHsfi6sHx2+L8aT6BFJnT7DcGFy6dWlAzhQf4T171/SBaWltY20dlZRrFDEoREQYVVHAAA6AV/Hv+xJ+3N4y/ZO8Wi0ui+oeE9QkX7dYk5KdjLFzw49Ohr+tj4c/Efwb8WPB1l498A30eoaZqEYkiljOevVWHZh0IPIr+TPGXLs6p5p9Zxz5sO9KbXwxX8rXSXd/a3Wmi/t76P8AmHD08l+qZZDkxUdaydueT/mT0vDsl8Oz1d33FFFFfjR/QB4x8dPgN8O/2hvA1x4F+Idms8MgJhmAxLbydnjbqCP1r8RPg3/wSV8Z/wDC9r7T/izMreDNIlEkM8TYfUVPKpgcoAPv+/Ar+iKivrsh43zbKMLWweCq2hUXXXlf80eza0/HdJnwPFHhpkHEGOw+YZlQ5qlJ7rTnXSM/5op6/hs2jD8NeGdA8HaHbeGvC9nFYWFmgjhghUIiKOwArcoor5Oc5Tk5Sd292fd06cacVCCtFaJLZLsgoooqSz//1/7+KKKKACiiigAooooAK8J/aK/aG+H37M/wzvPiX8QrgRwwDbb26kebczH7saDuSep7DmvdW3bTt69s1/Hj/wAFS9c/acu/2hbiw+Psf2fTYWf+w47bd9ha2zw0ZPWQj7+eQfav0Dw44PpcRZssLXqqFOK5pK9pSS6RXfu+i1PzvxN4zrcN5PLF4ei51JPli7XjFv7U327Lq9Dwr9qv9rn4lftZ+Pv+Ev8AG8i29na7ksNPiJ8m2iJ7Ak5Y/wATHrXy/wDacDJNYfn45PFftR/wTX/4Ju6j8aryz+OXxttpLXwtbSrJY2Mi7W1Bl53MD0hB/wC+vpX9jZpmGU8LZT7WolTo01aMVu30jFdW/wDNvqz+HcryTOeLs4dODdSvUd5Tlsl1lJ9Eui9Elsix/wAE8/8Agmpc/Hq3HxZ+OcFxY+F8f6Daj93Jen++eMiMdum76V88ft4fsM+LP2RvGH9p6MJtS8G6gxNnfMMmFj/yxmIAAYfwnuPev7DbGxs9Ms4tP0+JYIIFCRxoNqqq8AADoBXL+P8AwB4R+KHhG+8C+OrGPUNM1CMxTQyjIIPcehHUEdDX8x4PxqzWOdvH11fDS0dJbKPRp/zrdvrtta39V47wCyWeQRy7D6YqOqrPeUuqkv5Hsl9ndXd7/wACwuGHevvT9iL9u7x1+yP4n+wMDqXhPUJVN/YMTlOxlh/uuB+BqH9vD9hXxl+yD4v/ALS03zNT8HajIfsV8VyYSf8AljNjgMOx/iHvX59C6bHav6fjDKeJsqurVcPVX9ecZRfzTP5LdLOeE850vRxNJ/15SjJfJo/v3+GnxJ8HfF3wRp/xC8BXiX2l6lEJYZEPr1Vh2YdCDyDXd1/PD/wRa8KftJW8moeKfPNp8N7kMBBdKT9ouR/Hbgn5QP4m6Gv6Hq/iHjXh6lkmb1svoVlUjF6Nbq/2ZdOZdbfhsf6AcC8SVs9yahmWIoOlOS1T2dvtR68r3V/x3ZRRRXyh9eFFFFABRRRQB//Q/v4ooooAKKKKACiiigAr5u/aj/Zg+HX7VvwyuPh14+i2N/rLO8jA861mHR0Pp2YdCOK+kaK6sDjq+DxEMVhZuFSDumt00cmOwOHxuHnhcVBTpzVpJ7NM/nF/ZW/4I2eINL+MV9rH7Rk0Vz4d0G5H2GCA8anjlXfuiDjcvJJ46V/RfY2FlpdlFpumxJBbwII444wFVEUYAAHAAFW6K9/injHM+Ia8a+Y1L8qsorSK7tLu3q3+iSPn+E+C8q4dw86GW07czvJvWT7Jvstkv1bYUUUV8sfVnEfEb4c+Dvix4Mv/AAB49sY9Q0vUYjFNDIMjB7j0YdQRyDX4HeH/APgiNJB+0LKNe1vzvhzARcxBeLyUEn/R27ADu46jtmv6KKK+r4d42zjI6Vajl1ZxjUVmt7P+aN9pW0uv0R8lxJwNk2e1aFfMqCnKk7p7XX8srbxvrZ/qzn/CnhXw/wCCPDll4R8K2sdlp2nQrBbwRDCoiDAAFdBRRXy05ynJzm7t6tvqfVwhGEVCCsloktkgoooqSgooooAKKKKAP//R/v4ooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/Z"), contactLink = Just (either error CLFull $ strDecode "simplex:/contact/#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FShQuD-rPokbDvkyotKx5NwM8P3oUXHxA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA6fSx1k9zrOmF0BJpCaTarZvnZpMTAVQhd3RkDQ35KT0%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"), peerType = Just CPTBot, - preferences = Nothing + preferences = Nothing, + badge = Nothing } timeItToView :: String -> CM' a -> CM' a diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 87b560d1ab..f8cb2b861c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -437,9 +437,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- [incognito] send saved profile (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = case gInfo_ of - Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + profileToSend <- + presentUserBadge user incognitoProfile $ case gInfo_ of + Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do @@ -555,7 +556,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId - let p = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True + p <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True allowAgentConnectionAsync user conn'' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' XGrpLinkInv glInv -> do @@ -566,7 +567,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" @@ -798,7 +799,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn' confId $ XInfo profileToSend toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" @@ -813,7 +814,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile @@ -921,7 +922,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected @@ -1170,7 +1171,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = relayLinkData_ <- liftIO $ decodeLinkUserData cData case (relayLinkData_, linkEntityId) of (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> - withStore $ \db -> updateRelayMemberData db user m (MemberId entityId) (MemberKey relayKey) p + withStore $ \db -> updateRelayMemberData db cxt user m (MemberId entityId) (MemberKey relayKey) p _ -> throwChatError $ CEException "relay link: no relay link data or entity id" case cReq of CRContactUri crData@ConnReqUriData {crClientData} -> do @@ -1184,8 +1185,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- Update connection with data derived from cReq, now available after getConnShortLinkAsync withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup let GroupMember {memberId = membershipMemId} = membership - incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo - profileToSend = userProfileInGroup user gInfo incognitoProfile + incognitoProfile = incognitoMembershipProfile gInfo + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) memberPubKey <- case groupKeys gInfo of Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" @@ -2550,14 +2551,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processContactProfileUpdate :: Contact -> Profile -> Bool -> CM Contact processContactProfileUpdate c@Contact {profile = lp} p' createItems - | p /= p' = do + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify lp = do c' <- withStore $ \db -> if userTTL == rcvTTL - then updateContactProfile db user c p' + then updateContactProfile db cxt user c p' else do c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' - updateContactProfile db user c' p' - when (directOrUsed c' && createItems) $ do + updateContactProfile db cxt user c' p' + when (contentChanged && directOrUsed c' && createItems) $ do createProfileUpdatedItem c' lift $ createRcvFeatureItems user c c' toView $ CEvtContactUpdated user c c' @@ -2565,6 +2567,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure c where + contentChanged = not (sameProfileContent p p') p = fromLocalProfile lp Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs @@ -2667,22 +2670,23 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' msgTs_ - | redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do - updateBusinessChatProfile gInfo + -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key + | contentChanged || badgeNeedsReverify p = do + when contentChanged $ updateBusinessChatProfile gInfo case memberContactId of Nothing -> do - m' <- withStore $ \db -> updateMemberProfile db user m p' + m' <- withStore $ \db -> updateMemberProfile db cxt user m p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' Just mContactId -> do mCt <- withStore $ \db -> getContact db cxt user mContactId if canUpdateProfile mCt then do - (m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p' + (m', ct') <- withStore $ \db -> updateContactMemberProfile db cxt user m mCt p' unless (muteEventInChannel gInfo m') $ do - forM_ msgTs_ $ createProfileUpdatedItem m' + when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' toView $ CEvtContactUpdated user mCt ct' pure m' @@ -2696,6 +2700,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise = pure m where + contentChanged = not (sameProfileContent (redactedMemberProfile allowSimplexLinks (fromLocalProfile p)) (redactedMemberProfile allowSimplexLinks p')) allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do @@ -2976,7 +2981,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing Left _ -> do (newMember, gInfo') <- withStore $ \db -> do - newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus + newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus gInfo' <- if memberPending newMember then liftIO $ increaseGroupMembersRequireAttention db user gInfo @@ -3028,7 +3033,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = MemberInfo mId mRole v p _ | mRole == GROwner -> MemberInfo mId mRole v p Nothing _ -> memInfo - void $ withStore $ \db -> createIntroReMember db user gInfo memInfo' memRestrictions + void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' memRestrictions | otherwise -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) case memChatVRange of @@ -3040,7 +3045,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createConn subMode let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> do - reMember <- createIntroReMember db user gInfo memInfo memRestrictions + reMember <- createIntroReMember db cxt user gInfo memInfo memRestrictions createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" @@ -3075,7 +3080,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. `catchError` \case - SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db cxt user gInfo m memInfo GCPostMember GSMemAnnounced e -> throwError e -- TODO [knocking] separate pending statuses from GroupMemberStatus? -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? @@ -3085,8 +3090,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure toMember subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + let allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode @@ -3385,7 +3390,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createItems mCt m' joinConn subMode = do -- [incognito] send membership incognito profile - let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user Nothing True connReq dm subMode diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 281fc6b03b..d932194934 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -38,6 +38,7 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -136,6 +137,10 @@ foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt +foreign export ccall "chat_badge_keygen" cChatBadgeKeygen :: IO CJSONString + +foreign export ccall "chat_badge_issue" cChatBadgeIssue :: CString -> IO CJSONString + foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString diff --git a/src/Simplex/Chat/Mobile/Badges.hs b/src/Simplex/Chat/Mobile/Badges.hs new file mode 100644 index 0000000000..91e90e16c3 --- /dev/null +++ b/src/Simplex/Chat/Mobile/Badges.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} + +module Simplex.Chat.Mobile.Badges + ( cChatBadgeKeygen, + cChatBadgeIssue, + BadgeResult (..), + BadgeIssueReq (..), + IssuerKeyPair (..), + ) +where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString as B +import Data.Text (Text) +import qualified Data.Text as T +import Foreign.C (CString) +import Simplex.Chat.Badges +import Simplex.Chat.Mobile.Shared (CJSONString, newCStringFromLazyBS) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) +import Simplex.Messaging.Parsers (defaultJSON) + +-- FFI envelope for a generated issuer keypair (the BBS keypair tuple serialized with named fields) +data IssuerKeyPair = IssuerKeyPair + { publicKey :: BBSPublicKey, + secretKey :: BBSSecretKey + } + +data BadgeIssueReq = BadgeIssueReq + { badgeKeyIdx :: Int, + secretKey :: BBSSecretKey, + request :: BadgeRequest + } + +data BadgeResult r + = BadgeResult {result :: r} + | BadgeError {error :: Text} + +$(JQ.deriveJSON defaultJSON ''IssuerKeyPair) + +$(JQ.deriveJSON defaultJSON ''BadgeIssueReq) + +$(pure []) + +instance ToJSON r => ToJSON (BadgeResult r) where + toEncoding = $(JQ.mkToEncoding (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + toJSON = $(JQ.mkToJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +instance FromJSON r => FromJSON (BadgeResult r) where + parseJSON = $(JQ.mkParseJSON (defaultJSON {J.sumEncoding = J.UntaggedValue}) ''BadgeResult) + +cChatBadgeKeygen :: IO CJSONString +cChatBadgeKeygen = + bbsKeyGen >>= \case + Right (pk, sk) -> encodeResult $ BadgeResult (IssuerKeyPair pk sk) + Left e -> encodeResult @IssuerKeyPair $ BadgeError (T.pack e) + +cChatBadgeIssue :: CString -> IO CJSONString +cChatBadgeIssue cReq = do + bs <- B.packCString cReq + encodeResult @BadgeCredential =<< case J.eitherDecodeStrict' bs of + Left e -> pure $ BadgeError (T.pack e) + Right BadgeIssueReq {badgeKeyIdx, secretKey, request} -> + either (BadgeError . T.pack) BadgeResult <$> issueBadge badgeKeyIdx secretKey (VerifiedBadgeRequest request) + +encodeResult :: ToJSON r => BadgeResult r -> IO CJSONString +encodeResult = newCStringFromLazyBS . J.encode diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index b0903589de..7d272481f6 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -10,7 +10,7 @@ generateRandomProfile :: IO Profile generateRandomProfile = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 9b8f6da766..f8ccaa74e7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -49,6 +49,7 @@ import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) +import Simplex.Chat.Badges (LocalBadge) import Simplex.Chat.Call import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types @@ -1483,7 +1484,10 @@ instance FromField (ChatMessage 'Json) where data ContactShortLinkData = ContactShortLinkData { profile :: Profile, message :: Maybe MsgContent, - business :: Bool + business :: Bool, + -- set by the receiving client for the UI: the link profile's badge, verified and crypto-free. + -- never part of the published link data (the link carries the proof inside profile). + localBadge :: Maybe LocalBadge } deriving (Show) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index abc40f1e6e..e5ebf8e2bd 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -29,7 +29,8 @@ import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (fromMaybe) -import Data.Time.Clock (getCurrentTime) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Badges (rowToBadge) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -104,8 +105,9 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do (userId, agentConnId, ConnDeleted) getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact getContactRec_ contactId c = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getDirectChatTags db contactId - firstRow (toContact' contactId c chatTags) (SEInternalError "referenced contact not found") $ + firstRow (toContact' currentTs contactId c chatTags) (SEInternalError "referenced contact not found") $ DB.query db [sql| @@ -113,15 +115,16 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 |] (userId, contactId, CSActive) - toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} + toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact + toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn @@ -130,9 +133,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do + currentTs <- liftIO getCurrentTime gm <- ExceptT $ - firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + firstRow (toGroupAndMember currentTs c) (SEInternalError "referenced group member not found") $ DB.query db [sql| @@ -152,11 +156,13 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -170,10 +176,10 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do |] (groupMemberId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) liftIO $ bitraverse (addGroupChatTags db) pure gm - toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) - toGroupAndMember c (groupInfoRow :. memberRow) = - let groupInfo = toGroupInfo cxt userContactId [] groupInfoRow - member = toGroupMember userContactId memberRow + toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) + toGroupAndMember currentTs c (groupInfoRow :. memberRow) = + let groupInfo = toGroupInfo currentTs cxt userContactId [] groupInfoRow + member = toGroupMember currentTs userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact getUserContact_ userContactLinkId = ExceptT $ do diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 27cb970b73..9c5fe0cd91 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -24,6 +24,7 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) +import Simplex.Chat.Badges (badgeToRow, verifyBadge_) import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -72,7 +73,7 @@ createOrUpdateContactRequest isSimplexTeam invId cReqChatVRange@(VersionRange minV maxV) - profile@Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + profile@Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} xContactId_ welcomeMsgId_ requestMsg_ @@ -103,8 +104,9 @@ createOrUpdateContactRequest where getAcceptedContact :: XContactId -> IO (Maybe Contact) getAcceptedContact xContactId = do + currentTs <- getCurrentTime ct_ <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -114,6 +116,7 @@ createOrUpdateContactRequest cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- 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.xcontact_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.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -127,26 +130,29 @@ createOrUpdateContactRequest mapM (addDirectChatTags db) ct_ getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) getAcceptedBusinessChat xContactId = do + currentTs <- getCurrentTime g_ <- - maybeFirstRow (toGroupInfo cxt userContactId []) $ + maybeFirstRow (toGroupInfo currentTs cxt userContactId []) $ DB.query db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") (xContactId, userId, userContactId) mapM (addGroupChatTags db) g_ getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) - getContactRequestByXContactId xContactId = - maybeFirstRow toContactRequest $ + getContactRequestByXContactId xContactId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -157,12 +163,13 @@ createOrUpdateContactRequest createContactRequest :: ExceptT StoreError IO RequestStage createContactRequest = do currentTs <- liftIO $ getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId) :. ("" :: LocalAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db liftIO $ DB.execute @@ -214,7 +221,7 @@ createOrUpdateContactRequest ucr <- getContactRequest db user contactRequestId pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage - updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = LocalProfile {displayName = oldDisplayName}} = do currentTs <- liftIO getCurrentTime liftIO $ updateProfile currentTs updateRequest currentTs @@ -222,7 +229,8 @@ createOrUpdateContactRequest re_ <- getRequestEntity ucr' pure $ RSCurrentRequest (Just ucr) ucr' re_ where - updateProfile currentTs = + updateProfile currentTs = do + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge DB.execute db [sql| @@ -232,7 +240,16 @@ createOrUpdateContactRequest short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -240,7 +257,7 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) + ((displayName, fullName, shortDescr, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) updateRequest currentTs = if displayName == oldDisplayName then diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index e60d51ac85..204b5325ed 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -351,7 +351,8 @@ getGroupMembersByCursor db cxt user@User {userContactId} GroupInfo {groupId} cur :. (cursorGMId, count) ) #if defined(dbPostgres) - map (toContactMember cxt user) <$> + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_member_id IN ?") diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 1c2f35f2bf..5068c5c61c 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -105,6 +105,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality +import Simplex.Chat.Badges (badgeToRow) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -307,8 +308,9 @@ getConnReqContactXContactId db cxt user@User {userId} cReqHash1 cReqHash2 = getContactByConnReqHash :: DB.Connection -> StoreCxt -> User -> ConnReqUriHash -> ConnReqUriHash -> IO (Maybe Contact) getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do + currentTs <- getCurrentTime ct <- - maybeFirstRow (toContact cxt user []) $ + maybeFirstRow (toContact currentTs cxt user []) $ DB.query db [sql| @@ -318,6 +320,7 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- 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.xcontact_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.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -399,7 +402,7 @@ createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs + contactId <- createContact_ db cxt user p ctUserPreferences prepared "" currentTs getContact db cxt user contactId updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -444,7 +447,7 @@ createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profil createDirectContact db cxt user Connection {connId, localAlias} p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs + contactId <- createContact_ db cxt user p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db cxt user contactId @@ -552,22 +555,25 @@ deleteUnusedProfile_ db userId profileId = :. (userId, profileId, userId, profileId, profileId) ) -updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact -updateContactProfile db user@User {userId} c p' - | displayName == newName = do - liftIO $ updateContactProfile_ db userId profileId p' - pure c {profile, mergedPreferences} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} +updateContactProfile :: DB.Connection -> StoreCxt -> User -> Contact -> Profile -> ExceptT StoreError IO Contact +updateContactProfile db cxt user@User {userId} c p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) lp p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactProfile' currentTs badgeVerified profile where - Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c + Contact {contactId, localDisplayName, profile = lp@LocalProfile {profileId, displayName, localAlias}, userPreferences} = c Profile {displayName = newName, preferences} = p' - profile = toLocalProfile profileId p' localAlias mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c + updateContactProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ updateContactProfile_' db userId profileId p' badgeVerified currentTs + pure c {profile, mergedPreferences} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do @@ -694,55 +700,58 @@ setQuotaErrCounter db User {userId} Connection {connId} counter = do updatedAt <- getCurrentTime DB.execute db "UPDATE connections SET quota_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ?" (counter, updatedAt, userId, connId) -updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateContactProfile_ db userId profileId profile = do +updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateContactProfile_' db userId profileId profile currentTs + updateContactProfile_' db userId profileId profile badgeVerified currentTs -updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt = do +updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) -- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs) -updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfileReset_ db userId profileId profile = do +updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfileReset_' db userId profileId profile currentTs + updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) -updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () -updateMemberContactProfile_ db userId profileId profile = do +updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () +updateMemberContactProfile_ db userId profileId profile badgeVerified = do currentTs <- getCurrentTime - updateMemberContactProfile_' db userId profileId profile currentTs + updateMemberContactProfile_' db userId profileId profile badgeVerified currentTs -updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () -updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image} updatedAt = do +updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () +updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? |] - (displayName, fullName, shortDescr, image, updatedAt, userId, profileId) + ((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. (userId, profileId)) updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do @@ -773,18 +782,21 @@ getUserContactLinkIdByCReq db contactRequestId = DB.query db "SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ?" (Only contactRequestId) getContactRequest :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserContactRequest -getContactRequest db User {userId} contactRequestId = - ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ +getContactRequest db User {userId} contactRequestId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactRequest currentTs) (SEContactRequestNotFound contactRequestId) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getContactRequest' :: DB.Connection -> User -> Int64 -> IO (Maybe UserContactRequest) -getContactRequest' db User {userId} contactRequestId = - maybeFirstRow toContactRequest $ +getContactRequest' db User {userId} contactRequestId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.user_id = ? AND cr.contact_request_id = ?") (userId, contactRequestId) getBusinessContactRequest :: DB.Connection -> User -> GroupId -> IO (Maybe UserContactRequest) -getBusinessContactRequest db _user groupId = - maybeFirstRow toContactRequest $ +getBusinessContactRequest db _user groupId = do + currentTs <- getCurrentTime + maybeFirstRow (toContactRequest currentTs) $ DB.query db (contactRequestQuery <> " WHERE cr.business_group_id = ?") (Only groupId) contactRequestQuery :: Query @@ -793,10 +805,11 @@ contactRequestQuery = SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) |] @@ -832,7 +845,7 @@ deleteContactRequest db User {userId} contactRequestId = do (userId, userId, contactRequestId, userId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> Profile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) +createContactFromRequest :: DB.Connection -> User -> Maybe Int64 -> ConnId -> VersionChat -> VersionRangeChat -> ContactName -> ProfileId -> LocalProfile -> Maybe XContactId -> Maybe IncognitoProfile -> SubscriptionMode -> PQSupport -> Bool -> IO (Contact, Connection) createContactFromRequest db user@User {userId, profile = LocalProfile {preferences}} uclId_ agentConnId connChatVersion cReqChatVRange localDisplayName profileId profile xContactId incognitoProfile subMode pqSup contactUsed = do currentTs <- getCurrentTime let userPreferences = fromMaybe emptyChatPrefs $ incognitoProfile >> preferences @@ -848,7 +861,7 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc Contact { contactId, localDisplayName, - profile = toLocalProfile profileId profile "", + profile, activeConn = Just conn, contactUsed, contactStatus = CSActive, @@ -904,8 +917,9 @@ getContact db cxt user contactId = getContact_ db cxt user contactId False getContact_ :: DB.Connection -> StoreCxt -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact getContact_ db cxt user@User {userId} contactId deleted = do + currentTs <- liftIO getCurrentTime chatTags <- liftIO $ getDirectChatTags db contactId - ExceptT . firstRow (toContact cxt user chatTags) (SEContactNotFound contactId) $ + ExceptT . firstRow (toContact currentTs cxt user chatTags) (SEContactNotFound contactId) $ DB.query db [sql| @@ -915,6 +929,7 @@ getContact_ db cxt user@User {userId} contactId deleted = do cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- 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.xcontact_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.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -928,8 +943,9 @@ getContact_ db cxt user@User {userId} contactId deleted = do (userId, contactId, BI deleted) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User -getUserByContactRequestId db contactRequestId = - ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ +getUserByContactRequestId db contactRequestId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) getContactConnections :: DB.Connection -> StoreCxt -> UserId -> Contact -> IO [Connection] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4e38ef83e2..c6b5684945 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -196,6 +196,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.Badges (BadgeRow, badgeToRow, verifyBadge_) import Simplex.Chat.Messages import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) @@ -225,12 +226,12 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) -toMaybeGroupMember _ _ = Nothing +toMaybeGroupMember :: UTCTime -> Int64 -> MaybeGroupMemberRow -> Maybe GroupMember +toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) +toMaybeGroupMember _ _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink createGroupLink db gVar user@User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole subMode = do @@ -634,7 +635,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b randHostId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_host_" <> randHostId hostProfile = profileFromName $ nameFromBS randHostId - (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user hostProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -789,7 +790,7 @@ updatePreparedUserAndHostMembers' |] (memberId, memberRole, membershipStatus, currentTs, groupMemberId' membership) updateHostMember currentTs = do - _ <- updateMemberProfile db user hostMember fromMemberProfile + _ <- updateMemberProfile db cxt user hostMember fromMemberProfile let MemberIdRole memberId memberRole = fromMember gmId = groupMemberId' hostMember liftIO $ @@ -839,7 +840,7 @@ createGroupViaLink' (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where insertHost_ currentTs groupId = do - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -1005,7 +1006,8 @@ getInProgressGroups db cxt user@User {userId} createdAtCutoff = do getBaseGroupDetails :: DB.Connection -> StoreCxt -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db cxt User {userId, userContactId} _contactId_ search_ = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where condition = @@ -1039,16 +1041,18 @@ getGroupInfoByName db cxt user gName = do getGroupInfo db cxt user gId getGroupMember :: DB.Connection -> StoreCxt -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMember db cxt user@User {userId} groupId groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMember db cxt user@User {userId} groupId groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") (groupId, groupMemberId, userId) getHostMember :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db cxt user groupId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupHostMemberNotFound groupId) $ +getHostMember db cxt user groupId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") @@ -1088,32 +1092,36 @@ toMentionedMember (groupMemberId, memberId, memberRole, displayName, localAlias) in CIMention {memberId, memberRef} getGroupMemberById :: DB.Connection -> StoreCxt -> User -> GroupMemberId -> ExceptT StoreError IO GroupMember -getGroupMemberById db cxt user@User {userId} groupMemberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFound groupMemberId) $ +getGroupMemberById db cxt user@User {userId} groupMemberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) getGroupMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember -getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getGroupMemberByIndex db cxt user GroupInfo {groupId} indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") (groupId, indexInGroup) getSupportScopeMemberByIndex :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember -getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ +getSupportScopeMemberByIndex db cxt user GroupInfo {groupId} scopeGMId indexInGroup = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByIndex indexInGroup) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) getGroupMemberByMemberId :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = - ExceptT . firstRow (toContactMember cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberByMemberId db cxt user GroupInfo {groupId} memberId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (toContactMember currentTs cxt user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") @@ -1146,8 +1154,9 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = (userId, groupId, memberId) getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = - map (toContactMember cxt user) +getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") @@ -1156,8 +1165,9 @@ getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = getGroupMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> [Int64] -> IO [GroupMember] getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") @@ -1169,8 +1179,9 @@ getGroupMembersByIndexes db cxt user gInfo indexesInGroup = do getSupportScopeMembersByIndexes :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do #if defined(dbPostgres) + currentTs <- getCurrentTime let GroupInfo {groupId} = gInfo - map (toContactMember cxt user) <$> + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") @@ -1181,7 +1192,8 @@ getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do getGroupModerators :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") @@ -1189,7 +1201,8 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") @@ -1197,7 +1210,8 @@ getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId getGroupMembersForExpiration :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember cxt user) + currentTs <- getCurrentTime + map (toContactMember currentTs cxt user) <$> DB.query db ( groupMemberQuery @@ -1361,7 +1375,7 @@ createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> Gr createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName displayName - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, memProfileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs groupMemberId <- createWithRandomId' db gVar $ \memId -> runExceptT $ do indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ @@ -1380,11 +1394,12 @@ createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {gro getGroupMemberById db cxt user groupMemberId getCreateRelayForMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember -getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = - liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure +getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = do + currentTs <- liftIO getCurrentTime + liftIO (getGroupMemberByRelayLink currentTs) >>= maybe createRelayMember pure where - getGroupMemberByRelayLink = - maybeFirstRow (toContactMember cxt user) $ + getGroupMemberByRelayLink currentTs = + maybeFirstRow (toContactMember currentTs cxt user) $ DB.query db #if defined(dbPostgres) @@ -1399,7 +1414,7 @@ getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo randRelayId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_relay_" <> randRelayId relayProfile = profileFromName $ nameFromBS randRelayId - (localDisplayName, profileId) <- createNewMemberProfile_ db user relayProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId groupMemberId <- liftIO $ do DB.execute @@ -1472,7 +1487,7 @@ setRelayLinkAccepted db cxt user m (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (relayKey, currentTs, gmId) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile (,) <$> getGroupMemberById db cxt user gmId <*> getGroupRelayByGMId db gmId setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () @@ -1519,8 +1534,8 @@ getRelayConfId db m = |] (Only (groupMemberId' m)) -updateRelayMemberData :: DB.Connection -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () -updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do +updateRelayMemberData :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberId -> MemberKey -> Profile -> ExceptT StoreError IO () +updateRelayMemberData db cxt user m memberId (MemberKey relayKey) profile = do currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -1531,7 +1546,7 @@ updateRelayMemberData db user m memberId (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (memberId, relayKey, currentTs, groupMemberId' m) - void $ updateMemberProfile db user m profile + void $ updateMemberProfile db cxt user m profile setGroupInProgressDone :: DB.Connection -> GroupInfo -> IO () setGroupInProgressDone db GroupInfo {groupId} = do @@ -1584,7 +1599,7 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb insertOwner_ currentTs groupId = do let MemberIdRole {memberId, memberRole} = fromMember VersionRange minV maxV = reqChatVRange - (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -1651,7 +1666,8 @@ isRelayGroupRejected db User {userId} groupLink = getRelayServedGroups :: DB.Connection -> StoreCxt -> User -> IO [GroupInfo] getRelayServedGroups db cxt User {userId, userContactId} = do - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1661,8 +1677,9 @@ getRelayServedGroups db cxt User {userId, userContactId} = do getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do - cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime - map (toGroupInfo cxt userContactId []) + currentTs <- getCurrentTime + let cutoffTs = addUTCTime (- ttl) currentTs + map (toGroupInfo currentTs cxt userContactId []) <$> DB.query db ( groupInfoQuery @@ -1697,14 +1714,15 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember db + cxt gVar User {userId, userContactId} GroupInfo {groupId, membership} cReqChatVRange - Profile {displayName, fullName, shortDescr, image, contactLink, preferences} + Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} cReqXContactId_ cReqMemberId_ welcomeMsgId_ @@ -1712,12 +1730,13 @@ createJoiningMember memberStatus memberKey_ = do currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge ExceptT . withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- liftIO $ insertedRowId db case cReqMemberId_ of Just memberId -> do @@ -2053,10 +2072,10 @@ increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, memb pure g {membersRequireAttention = membersRequireAttention + 1} -- | add new member with profile -createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do +createNewGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user profile currentTs let newMember = NewGroupMember { memInfo, @@ -2069,19 +2088,20 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs -createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) -createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, preferences} createdAt = +createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Maybe Bool) +createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) :. badgeToRow badge badgeVerified) profileId <- insertedRowId db - pure $ Right (ldn, profileId) + pure $ Right (ldn, profileId, badgeVerified) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> ExceptT StoreError IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} @@ -2097,6 +2117,7 @@ createNewMember_ memContactId = memberContactId, memProfileId = memberContactProfileId } + badgeVerified createdAt = do let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing @@ -2134,7 +2155,7 @@ createNewMember_ invitedBy, invitedByGroupMemberId = memInvitedByGroupMemberId, localDisplayName, - memberProfile = toLocalProfile memberContactProfileId memberProfile "", + memberProfile = toLocalProfile memberContactProfileId memberProfile "" createdAt badgeVerified, memberContactId, memberContactProfileId, activeConn, @@ -2248,18 +2269,19 @@ getMemberRelationsVector db GroupMember {groupMemberId} = "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" (Only groupMemberId) -createIntroReMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember createIntroReMember db + cxt user gInfo memInfo@(MemberInfo _ _ _ memberProfile _) memRestrictions_ = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile currentTs let memRestriction = restriction <$> memRestrictions_ newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember badgeVerified currentTs createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMemberConn @@ -2983,41 +3005,47 @@ setMemberContactStartedConnection db Contact {contactId} = do "UPDATE contacts SET grp_direct_inv_started_connection = ?, updated_at = ? WHERE contact_id = ?" (BI True, currentTs, contactId) -updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db user@User {userId} m p' - | displayName == newName = do - liftIO $ updateMemberContactProfileReset_ db userId profileId p' - pure m {memberProfile = profile} - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateMemberContactProfileReset_' 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) - safeDeleteLDN db user localDisplayName - pure $ Right m {localDisplayName = ldn, memberProfile = profile} +updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db cxt user@User {userId} m p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateMemberProfile' currentTs badgeVerified memberProfile where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName} = p' - profile = toLocalProfile profileId p' localAlias + updateMemberProfile' currentTs badgeVerified memberProfile + | displayName == newName = do + liftIO $ updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + pure m {memberProfile} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + safeDeleteLDN db user localDisplayName + pure $ Right m {localDisplayName = ldn, memberProfile} -updateContactMemberProfile :: DB.Connection -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' - | displayName == newName = do - liftIO $ updateMemberContactProfile_ db userId profileId p' - pure (m {memberProfile = profile}, ct {profile} :: Contact) - | otherwise = - ExceptT . withLocalDisplayName db userId newName $ \ldn -> do - currentTs <- getCurrentTime - updateMemberContactProfile_' db userId profileId p' currentTs - updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) +updateContactMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) +updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} p' = do + currentTs <- liftIO getCurrentTime + badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified + updateContactMemberProfile' currentTs badgeVerified profile where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m Profile {displayName = newName} = p' - profile = toLocalProfile profileId p' localAlias + updateContactMemberProfile' currentTs badgeVerified profile + | displayName == newName = do + liftIO $ updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + pure (m {memberProfile = profile}, ct {profile} :: Contact) + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs + updateContactLDN_ db user contactId localDisplayName ldn currentTs + pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = @@ -3036,7 +3064,7 @@ createNewUnknownGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3061,7 +3089,7 @@ createLinkOwnerMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupId} contactId_ memberId ownerKey = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName $ nameFromMemberId memberId - (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs + (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3087,7 +3115,7 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI -- Updating from an in-band message would allow a compromised relay to substitute keys. updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do - _ <- updateMemberProfile db user member profile + _ <- updateMemberProfile db cxt user member profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -3108,7 +3136,7 @@ updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupM updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do - _ <- updateMemberProfile db user unknownMember profile + _ <- updateMemberProfile db cxt user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 76e0a0fd97..edbe7a6acb 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -652,7 +652,8 @@ insertChatItemMessage_ :: DB.Connection -> ChatItemId -> MessageId -> UTCTime -> insertChatItemMessage_ db ciId msgId ts = DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, ts, ts) getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> QuotedMsg -> IO (CIQuote c) -getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = +getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = do + currentTs <- getCurrentTime case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> @@ -660,13 +661,13 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing CDChannelRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId - | otherwise -> getGroupChatItemQuote_ groupId mId + | otherwise -> getGroupChatItemQuote_ currentTs groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c @@ -695,8 +696,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" (userId, groupId, msgId, MDRcv, groupMemberId) - getGroupChatItemQuote_ :: Int64 -> MemberId -> IO (CIQuote 'CTGroup) - getGroupChatItemQuote_ groupId mId = do + getGroupChatItemQuote_ :: UTCTime -> Int64 -> MemberId -> IO (CIQuote 'CTGroup) + getGroupChatItemQuote_ currentTs groupId mId = do ciQuoteGroup <$> DB.query db @@ -706,6 +707,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -721,7 +723,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe where ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing - ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow + ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember currentTs userContactId memberRow getChatPreviews :: DB.Connection -> StoreCxt -> User -> Bool -> PaginationByTime -> ChatListQuery -> IO [Either StoreError AChat] getChatPreviews db cxt user withPCC pagination query = do @@ -1111,22 +1113,25 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} getContactRequestChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] -getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = False} -> pure [] - CLQFilters {favorite = False, unread = True} -> map toPreview <$> getPreviews "" - CLQFilters {favorite = True, unread = True} -> map toPreview <$> getPreviews "" - CLQSearch {search} -> map toPreview <$> getPreviews search +getContactRequestChatPreviews_ db User {userId} pagination clq = do + currentTs <- getCurrentTime + case clq of + CLQFilters {favorite = False, unread = False} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = False} -> pure [] + CLQFilters {favorite = False, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQFilters {favorite = True, unread = True} -> map (toPreview currentTs) <$> getPreviews "" + CLQSearch {search} -> map (toPreview currentTs) <$> getPreviews search where query = [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -1148,9 +1153,9 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of PTLast count -> DB.query db (query <> " ORDER BY cr.updated_at DESC LIMIT ?") (params search :. Only count) PTAfter ts count -> DB.query db (query <> " AND cr.updated_at > ? ORDER BY cr.updated_at ASC LIMIT ?") (params search :. (ts, count)) PTBefore ts count -> DB.query db (query <> " AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ?") (params search :. (ts, count)) - toPreview :: ContactRequestRow -> AChatPreviewData - toPreview cReqRow = - let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow + toPreview :: UTCTime -> ContactRequestRow -> AChatPreviewData + toPreview now cReqRow = + let cReq@UserContactRequest {updatedAt} = toContactRequest now cReqRow aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] emptyChatStats in ACPD SCTContactRequest $ ContactRequestPD updatedAt aChat @@ -2358,9 +2363,9 @@ toGroupChatItem ) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where - member_ = toMaybeGroupMember userContactId memberRow_ - quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - deletedByGroupMember_ = toMaybeGroupMember userContactId deletedByGroupMemberRow_ + member_ = toMaybeGroupMember currentTs userContactId memberRow_ + quotedMember_ = toMaybeGroupMember currentTs userContactId quotedMemberRow_ + deletedByGroupMember_ = toMaybeGroupMember currentTs userContactId deletedByGroupMemberRow_ invalid = ACIContent msgDir $ CIInvalidJSON itemContentText chatItem itemContent = case (itemContent, itemStatus, member_, fileStatus_) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> @@ -3036,6 +3041,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -3044,12 +3050,14 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index a8bb0da945..4c9a1b1c91 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -32,6 +32,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access +import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -63,7 +64,8 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..ffc3122e3f --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260516_supporter_badges :: Text +m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TIMESTAMPTZ; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified SMALLINT; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BYTEA; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx BIGINT; +|] + +down_m20260516_supporter_badges :: Text +down_m20260516_supporter_badges = + [r| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index cc3543e8a8..68c43efa19 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -531,7 +531,16 @@ CREATE TABLE test_chat_schema.contact_profiles ( preferences text, contact_link bytea, short_descr text, - chat_peer_type text + chat_peer_type text, + badge_proof bytea, + badge_pres_header bytea, + badge_expiry timestamp with time zone, + badge_type text, + badge_verified smallint, + badge_extra text, + badge_master_key bytea, + badge_signature bytea, + badge_key_idx bigint ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index d432067866..bfd198d885 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -43,6 +43,7 @@ module Simplex.Chat.Store.Profiles updateUserGroupReceipts, updateUserAutoAcceptMemberContacts, updateUserProfile, + setUserBadge, setUserProfileContactLink, getUserContactProfiles, createUserContactLink, @@ -97,6 +98,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Simplex.Chat.Badges (LocalBadge, localBadgeToRow) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Operators @@ -162,7 +164,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing, BI userChatRelay) + pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing, BI userChatRelay) :. localBadgeToRow Nothing -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -196,8 +198,9 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} getUsers :: DB.Connection -> IO [User] -getUsers db = - map toUser <$> DB.query_ db userQuery +getUsers db = do + now <- getCurrentTime + map (toUser now) <$> DB.query_ db userQuery setActiveUser :: DB.Connection -> User -> IO User setActiveUser db user@User {userId} = do @@ -214,13 +217,15 @@ getNextActiveOrder db = do else pure $ order + 1 getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User -getUser db userId = - ExceptT . firstRow toUser (SEUserNotFound userId) $ +getUser db userId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFound userId) $ DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) getRelayUser :: DB.Connection -> ExceptT StoreError IO User -getRelayUser db = - ExceptT . firstRow toUser SERelayUserNotFound $ +getRelayUser db = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) SERelayUserNotFound $ DB.query_ db (userQuery <> " WHERE u.is_user_chat_relay = 1") getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 @@ -229,38 +234,45 @@ getUserIdByName db uName = DB.query db "SELECT user_id FROM users WHERE local_display_name = ?" (Only uName) getUserByAConnId :: DB.Connection -> AgentConnId -> IO (Maybe User) -getUserByAConnId db agentConnId = - maybeFirstRow toUser $ +getUserByAConnId db agentConnId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId) getUserByASndFileId :: DB.Connection -> AgentSndFileId -> IO (Maybe User) -getUserByASndFileId db aSndFileId = - maybeFirstRow toUser $ +getUserByASndFileId db aSndFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.agent_snd_file_id = ?") (Only aSndFileId) getUserByARcvFileId :: DB.Connection -> AgentRcvFileId -> IO (Maybe User) -getUserByARcvFileId db aRcvFileId = - maybeFirstRow toUser $ +getUserByARcvFileId db aRcvFileId = do + now <- getCurrentTime + maybeFirstRow (toUser now) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id JOIN rcv_files r ON r.file_id = f.file_id WHERE r.agent_rcv_file_id = ?") (Only aRcvFileId) getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User -getUserByContactId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByContactId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ? AND ct.deleted = 0") (Only contactId) getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User -getUserByGroupId db groupId = - ExceptT . firstRow toUser (SEUserNotFoundByGroupId groupId) $ +getUserByGroupId db groupId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByGroupId groupId) $ DB.query db (userQuery <> " JOIN groups g ON g.user_id = u.user_id WHERE g.group_id = ?") (Only groupId) getUserByNoteFolderId :: DB.Connection -> NoteFolderId -> ExceptT StoreError IO User -getUserByNoteFolderId db contactId = - ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ +getUserByNoteFolderId db contactId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN note_folders nf ON nf.user_id = u.user_id WHERE nf.note_folder_id = ?") (Only contactId) getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User -getUserByFileId db fileId = - ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ +getUserByFileId db fileId = do + now <- liftIO getCurrentTime + ExceptT . firstRow (toUser now) (SEUserNotFoundByFileId fileId) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo] @@ -309,10 +321,10 @@ updateUserAutoAcceptMemberContacts db User {userId} autoAccept = updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do - updateContactProfile_ db userId profileId p' currentTs <- getCurrentTime + updateUserProfileFields_' db userId profileId p' currentTs userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs - pure user {profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime @@ -322,9 +334,9 @@ updateUserProfile db user p' db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) - updateContactProfile_' db userId profileId p' currentTs + updateUserProfileFields_' db userId profileId p' currentTs updateContactLDN_ db user userContactId localDisplayName newName currentTs - pure user {localDisplayName = newName, profile, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} + pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False)) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} where updateUserMemberProfileUpdatedAt_ currentTs | userMemberProfileChanged = do @@ -332,11 +344,38 @@ updateUserProfile db user p' pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localAlias}, userMemberProfileUpdatedAt} = user + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p' - profile = toLocalProfile profileId p' localAlias fullPreferences = fullPreferences' preferences +-- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge) +updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () +updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt = + DB.execute + db + [sql| + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + ((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId)) + +-- store the user's own badge credential; touches only the badge columns. +-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message. +setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User +setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do + ts <- getCurrentTime + DB.execute + db + [sql| + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (localBadgeToRow localBadge :. (ts, userId, profileId)) + DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) + pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts} + setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do ts <- getCurrentTime @@ -366,7 +405,7 @@ getUserContactProfiles db User {userId} = (Only userId) where toContactProfile :: (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) -> Profile - toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} + toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences, badge = Nothing} createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> ExceptT StoreError IO () createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 89ef373af8..5bf628b062 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -155,6 +155,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index import Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access +import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -309,7 +310,8 @@ schemaMigrations = ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), - ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access) + ("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access), + ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs new file mode 100644 index 0000000000..d263d63a2b --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260516_supporter_badges.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260516_supporter_badges :: Query +m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles ADD COLUMN badge_proof BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_pres_header BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_expiry TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_type TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_verified INTEGER; +ALTER TABLE contact_profiles ADD COLUMN badge_extra TEXT; +ALTER TABLE contact_profiles ADD COLUMN badge_master_key BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_signature BLOB; +ALTER TABLE contact_profiles ADD COLUMN badge_key_idx INTEGER; +|] + +down_m20260516_supporter_badges :: Query +down_m20260516_supporter_badges = + [sql| +ALTER TABLE contact_profiles DROP COLUMN badge_key_idx; +ALTER TABLE contact_profiles DROP COLUMN badge_signature; +ALTER TABLE contact_profiles DROP COLUMN badge_master_key; +ALTER TABLE contact_profiles DROP COLUMN badge_extra; +ALTER TABLE contact_profiles DROP COLUMN badge_verified; +ALTER TABLE contact_profiles DROP COLUMN badge_type; +ALTER TABLE contact_profiles DROP COLUMN badge_expiry; +ALTER TABLE contact_profiles DROP COLUMN badge_proof; +ALTER TABLE contact_profiles DROP COLUMN badge_pres_header; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 14c9226d2c..803e012773 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -125,6 +125,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- 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.xcontact_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.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -156,11 +157,13 @@ Query: mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -394,10 +397,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? @@ -462,7 +466,16 @@ Query: short_descr = ?, image = ?, contact_link = ?, - updated_at = ? + updated_at = ?, + badge_proof = ?, + badge_pres_header = ?, + badge_expiry = ?, + badge_type = ?, + badge_verified = ?, + badge_extra = ?, + badge_master_key = ?, + badge_signature = ?, + badge_key_idx = ? WHERE contact_profile_id IN ( SELECT contact_profile_id FROM contact_requests @@ -673,7 +686,8 @@ Query: c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.contact_status = ? AND c.deleted = 0 @@ -1024,6 +1038,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m @@ -1308,6 +1323,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem @@ -1316,12 +1332,14 @@ Query: rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, + rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, + dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i @@ -1374,6 +1392,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- 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.xcontact_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.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -1930,6 +1949,7 @@ Query: cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, -- 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.xcontact_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.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -2011,10 +2031,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2040,10 +2061,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -2069,10 +2091,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id @@ -3602,7 +3625,8 @@ Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? @@ -4976,6 +5000,14 @@ Query: Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET contact_link = ?, updated_at = ? @@ -4994,7 +5026,8 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5002,7 +5035,17 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE contact_profiles + SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?, + badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ? WHERE user_id = ? AND contact_profile_id = ? Plan: @@ -5319,6 +5362,7 @@ Query: mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5356,6 +5400,7 @@ Query: mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5386,6 +5431,7 @@ Query: mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link @@ -5404,10 +5450,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.business_group_id = ? @@ -5419,10 +5466,11 @@ Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id, cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version + cr.peer_chat_min_version, cr.peer_chat_max_version, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx FROM contact_requests cr JOIN contact_profiles p USING (contact_profile_id) WHERE cr.user_id = ? AND cr.contact_request_id = ? @@ -5434,6 +5482,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5461,6 +5510,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5481,6 +5531,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5500,6 +5551,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5519,6 +5571,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5538,6 +5591,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5557,6 +5611,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5576,6 +5631,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5595,6 +5651,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5614,6 +5671,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5633,6 +5691,7 @@ Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -5823,7 +5882,8 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5835,7 +5895,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5848,7 +5909,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5861,7 +5923,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5875,7 +5938,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5888,7 +5952,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5901,7 +5966,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5914,7 +5980,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5927,7 +5994,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5939,7 +6007,8 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -6531,11 +6600,15 @@ Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index ccff26b38d..2d7ea7ff70 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -19,7 +19,16 @@ CREATE TABLE contact_profiles( preferences TEXT, contact_link BLOB, short_descr TEXT, - chat_peer_type TEXT + chat_peer_type TEXT, + badge_proof BLOB, + badge_pres_header BLOB, + badge_expiry TEXT, + badge_type TEXT, + badge_verified INTEGER, + badge_extra TEXT, + badge_master_key BLOB, + badge_signature BLOB, + badge_key_idx INTEGER ) STRICT; CREATE TABLE users( user_id INTEGER PRIMARY KEY, diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index f7b525243c..bd0d22f379 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -32,6 +32,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality +import Simplex.Chat.Badges (BadgeRow, badgeToRow, rowToBadge, verifyBadge_) import Simplex.Chat.Messages import Simplex.Chat.Remote.Types import Simplex.Chat.Types @@ -406,18 +407,19 @@ setCommandConnId db User {userId} cmdId connId = do |] (connId, updatedAt, userId, cmdId) -createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () -createContact db user profile = do +createContact :: DB.Connection -> StoreCxt -> User -> Profile -> ExceptT StoreError IO () +createContact db cxt user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db user profile emptyChatPrefs Nothing "" currentTs + void $ createContact_ db cxt user profile emptyChatPrefs Nothing "" currentTs -createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} ctUserPreferences prepared localAlias currentTs = +createContact_ :: DB.Connection -> StoreCxt -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId +createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do + badgeVerified <- verifyBadge_ (badgeKeys cxt) badge DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs)) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified) profileId <- insertedRowId db DB.execute db @@ -484,13 +486,13 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow type ContactRow = Only ContactId :. ContactRow' -toContact :: StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} +toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact +toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow) :. connRow) = + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} activeConn = toMaybeConnection cxt connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn @@ -516,22 +518,24 @@ toGroupDirectInvitation (Just groupDirectInvLink, fromGroupId_, fromGroupMemberI Just $ GroupDirectInvitation {groupDirectInvLink, fromGroupId_, fromGroupMemberId_, fromGroupMemberConnId_, groupDirectInvStartedConnection} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile -getProfileById db userId profileId = - ExceptT . firstRow rowToLocalProfile (SEProfileNotFound profileId) $ +getProfileById db userId profileId = do + currentTs <- liftIO getCurrentTime + ExceptT . firstRow (rowToLocalProfile currentTs) (SEProfileNotFound profileId) $ DB.query db [sql| - SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences -- , ct.user_preferences + SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, + cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx FROM contact_profiles cp WHERE cp.user_id = ? AND cp.contact_profile_id = ? |] (userId, profileId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow -toContactRequest :: ContactRequestRow -> UserContactRequest -toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer)) = do - let profile = Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} +toContactRequest :: UTCTime -> ContactRequestRow -> UserContactRequest +toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow) = do + let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias} cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer in UserContactRequest {contactRequestId, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, createdAt, updatedAt} @@ -539,17 +543,18 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay, + ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides, BoolInt) -> User -toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes, BI userChatRelay)) = +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides, BoolInt) :. BadgeRow -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes, BI userChatRelay) :. badgeRow) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts = BoolDef autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, uiThemes, userChatRelay = BoolDef userChatRelay} where - profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences = userPreferences, localAlias = ""} + profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ @@ -671,11 +676,11 @@ type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolIn type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) -type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) +type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow -toGroupInfo :: StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = - let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr cxt} +toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = + let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) @@ -718,9 +723,9 @@ toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) toGroupKeys _ _ = Nothing -toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = - let memberProfile = rowToLocalProfile profileRow +toGroupMember :: UTCTime -> Int64 -> GroupMemberRow -> GroupMember +toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + let memberProfile = rowToLocalProfile now profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById @@ -745,6 +750,7 @@ groupMemberQuery = SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, 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.xcontact_id, c.custom_user_profile_id, @@ -756,13 +762,13 @@ groupMemberQuery = LEFT JOIN connections c ON c.group_member_id = m.group_member_id |] -toContactMember :: StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember cxt User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} +toContactMember :: UTCTime -> StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember now cxt User {userContactId} (memberRow :. connRow) = + (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} -rowToLocalProfile :: ProfileRow -> LocalProfile -rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences} +rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile +rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow) = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences} toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId} @@ -789,6 +795,7 @@ groupInfoQueryFields = mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link |] @@ -877,8 +884,9 @@ addGroupChatTags db g@GroupInfo {groupId} = do getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db cxt User {userId, userContactId} groupId = ExceptT $ do + currentTs <- getCurrentTime chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo cxt userContactId chatTags) (SEGroupNotFound groupId) $ + firstRow (toGroupInfo currentTs cxt userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b4264d121d..7155d407e8 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -40,6 +40,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Functor (($>)) import Data.Int (Int64) +import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -47,6 +48,8 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Data.Word (Word16) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), localBadgeInfo, localBadgeStatus, mkBadgeStatus, verifyBadge) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -367,7 +370,7 @@ data UserContactRequest = UserContactRequest cReqChatVRange :: VersionRangeChat, localDisplayName :: ContactName, profileId :: Int64, - profile :: Profile, + profile :: LocalProfile, createdAt :: UTCTime, updatedAt :: UTCTime, xContactId :: Maybe XContactId, @@ -685,7 +688,8 @@ data Profile = Profile image :: Maybe ImageData, contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, - peerType :: Maybe ChatPeerType + peerType :: Maybe ChatPeerType, + badge :: Maybe BadgeProof -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: -- - contact_profile_id -- - incognito @@ -718,7 +722,7 @@ instance TextEncoding ChatPeerType where profileFromName :: ContactName -> Profile profileFromName displayName = - Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing} + Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing} -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool @@ -727,6 +731,15 @@ profilesMatch LocalProfile {displayName = n2, fullName = fn2, image = i2} = n1 == n2 && fn1 == fn2 && i1 == i2 +-- equal for profile-update detection: badge proofs are re-generated for every presentation, +-- so compare badges by disclosed info (not proof bytes) - a re-presentation of the same badge is a no-op +sameProfileContent :: Profile -> Profile -> Bool +sameProfileContent p@Profile {badge = b} p'@Profile {badge = b'} = + p {badge = Nothing} == p' {badge = Nothing} && (proofInfo <$> b) == (proofInfo <$> b') + where + proofInfo :: BadgeProof -> BadgeInfo + proofInfo (BadgeProof _ _ _ info) = info + data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile fromIncognitoProfile :: IncognitoProfile -> Profile @@ -758,6 +771,7 @@ data LocalProfile = LocalProfile contactLink :: Maybe ConnLinkContact, preferences :: Maybe Preferences, peerType :: Maybe ChatPeerType, + localBadge :: Maybe LocalBadge, localAlias :: LocalAlias } deriving (Eq, Show) @@ -765,13 +779,37 @@ data LocalProfile = LocalProfile localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile {profileId} = profileId -toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile -toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} localAlias = - LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localAlias} +toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> LocalProfile +toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge} localAlias now verified = + LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias} + where + localBadge = (\b@(BadgeProof _ _ _ info) -> PeerBadge b (mkBadgeStatus now verified info)) <$> badge fromLocalProfile :: LocalProfile -> Profile -fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} = - Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} +fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge} = + Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge} + where + -- any stored peer proof rides the wire (receivers verify independently); the own credential is presented fresh, and a display-only badge never sends + wireBadge :: LocalBadge -> Maybe BadgeProof + wireBadge = \case + PeerBadge b _ -> Just b + OwnBadge _ _ -> Nothing + ShownBadge _ _ -> Nothing + +profileBadgeVerified :: Map Int BBSPublicKey -> LocalProfile -> Profile -> IO (Maybe Bool) +profileBadgeVerified keys LocalProfile {localBadge} Profile {badge = newBadge} = + case (localBadge, newBadge) of + (_, Nothing) -> pure (Just False) + -- an unchanged badge that verified before stays verified; failed or unknown-key badges + -- are re-verified, so an unknown key heals once an app update adds it + (Just lb, Just (BadgeProof _ _ _ newInfo)) + | localBadgeInfo lb == newInfo && localBadgeStatus lb `notElem` [BSFailed, BSUnknownKey] -> pure (Just True) + (_, Just newB) -> verifyBadge keys newB + +-- a failed or unknown-key badge is re-verified on the next profile update even when its disclosed content +-- is unchanged, so it heals once an app update adds the issuer key +badgeNeedsReverify :: LocalProfile -> Bool +badgeNeedsReverify LocalProfile {localBadge} = maybe False ((`elem` [BSFailed, BSUnknownKey]) . localBadgeStatus) localBadge data GroupType = GTChannel @@ -2035,7 +2073,7 @@ type VersionRangeChat = VersionRange ChatVersion -- | Store-wide context passed to store functions in place of the bare `vr` -- parameter. Built from config by mkStoreCxt; more fields are added here over time. -newtype StoreCxt = StoreCxt {vr :: VersionRangeChat} +data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublicKey} pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 838d15245a..004f6af825 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -43,6 +43,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Help import Simplex.Chat.Library.Commands (maxImageSize) import Simplex.Chat.Markdown +import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -111,7 +112,7 @@ chatErrorToView isCmd ChatConfig {logLevel, testView} = viewChatError isCmd logL chatResponseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString] chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveItems ts tz outputRH = \case - CRActiveUser User {profile, uiThemes} -> viewUserProfile (fromLocalProfile profile) <> viewUITheme uiThemes + CRActiveUser User {profile = p@LocalProfile {localBadge}, uiThemes} -> viewUserProfile localBadge (fromLocalProfile p) <> viewUITheme uiThemes CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] @@ -193,7 +194,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci - CRUserProfile u p -> ttyUser u $ viewUserProfile p + CRUserProfile u@User {profile = LocalProfile {localBadge}} p -> ttyUser u $ viewUserProfile localBadge p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix hu outputRH u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info @@ -452,7 +453,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtRcvFileProgressXFTP {} -> [] CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CEvtGroupMemberUpdated {} -> [] - CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c profile + CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c (fromLocalProfile profile) CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci CEvtRcvStandaloneFileComplete u _ ft -> ttyUser u $ receivingFileStandalone "completed" ft @@ -618,8 +619,8 @@ viewUsersList us = in if null ss then ["no users"] else ss where ldn (UserInfo User {localDisplayName = n} _) = T.toLower n - userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType}, activeUser, showNtfs, viewPwdHash} count) - | activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName shortDescr <> infoStr <> bot + userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, peerType, localBadge}, activeUser, showNtfs, viewPwdHash} count) + | activeUser || isNothing viewPwdHash = Just $ ttyFullNameBadge n fullName shortDescr localBadge <> infoStr <> bot | otherwise = Nothing where infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")" @@ -1507,9 +1508,9 @@ viewContactAndMemberAssociated ct g m ct' = "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] -viewUserProfile :: Profile -> [StyledString] -viewUserProfile Profile {displayName, fullName, shortDescr, peerType, preferences} = - [ "user profile: " <> ttyFullName displayName fullName shortDescr <> bot, +viewUserProfile :: Maybe LocalBadge -> Profile -> [StyledString] +viewUserProfile localBadge Profile {displayName, fullName, shortDescr, peerType, preferences} = + [ "user profile: " <> ttyFullNameBadge displayName fullName shortDescr localBadge <> bot, "use " <> highlight' "/p []" <> " to change it" ] ++ viewCommands @@ -1752,9 +1753,22 @@ smpProxyModeStr :: SMPProxyMode -> SMPProxyFallback -> String smpProxyModeStr SPMNever _ = "private message routing disabled." smpProxyModeStr mode fallback = T.unpack $ safeDecodeUtf8 $ "private message routing mode: " <> strEncode mode <> ", fallback: " <> strEncode fallback +viewContactBadge :: Maybe LocalBadge -> [StyledString] +viewContactBadge = maybe [] $ \lb -> + let BadgeInfo {badgeType, badgeExpiry} = localBadgeInfo lb + st = case localBadgeStatus lb of + BSActive -> "active" + BSExpired -> "expired" + BSExpiredOld -> "expired (old)" + BSFailed -> "verification failed" + BSUnknownKey -> "unknown key" + expiry = maybe "no expiry" (("expires " <>) . T.pack . formatTime defaultTimeLocale "%Y-%m-%d") badgeExpiry + in [plain (textEncode badgeType <> " badge - " <> st), plain expiry] + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn, uiThemes, customData} stats incognitoProfile = +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] + <> viewContactBadge localBadge <> maybe [] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> maybe @@ -1787,10 +1801,11 @@ viewCustomData :: Maybe CustomData -> [StyledString] viewCustomData = maybe [] (\(CustomData v) -> ["custom data: " <> viewJSON (J.Object v)]) viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink}, activeConn} stats = +viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink, localBadge}, activeConn} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] + <> viewContactBadge localBadge <> maybe ["member not connected"] viewConnectionStats stats <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink <> ["alias: " <> plain localAlias | localAlias /= ""] @@ -2785,9 +2800,47 @@ ttyContact = styled (colored Green) . viewName ttyContact' :: Contact -> StyledString ttyContact' Contact {localDisplayName = c} = ttyContact c +-- Supporter badge: a colored star marks an active badge (only the star is colored). +-- supporter cyan, legend blue, investor yellow, unknown cyan; business has no star. +badgeStarColor :: BadgeType -> Maybe Color +badgeStarColor = \case + BTSupporter -> Just Cyan + BTLegend -> Just Blue + BTInvestor -> Just Yellow + BTUnknown _ -> Just Cyan + +-- (star color, type word) for an active, colorable badge +activeBadge :: Maybe LocalBadge -> Maybe (Color, Text) +activeBadge lb_ = do + lb <- lb_ + case localBadgeStatus lb of + BSActive -> let BadgeInfo {badgeType} = localBadgeInfo lb in (\col -> (col, textEncode badgeType)) <$> badgeStarColor badgeType + _ -> Nothing + +badgeStar :: Color -> StyledString +badgeStar col = styled (colored col) ("*" :: Text) + +-- " *" (space + colored star) for sender prefixes, "" if no active badge +badgeStarSep :: Maybe LocalBadge -> StyledString +badgeStarSep lb_ = maybe "" (\(c, _) -> " " <> badgeStar c) (activeBadge lb_) + +-- name + badge for full-name contexts: "alice (Alice, * supporter)" / "alice (* supporter)" / "alice (Alice)" / "alice" +ttyFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +ttyFullNameBadge c fullName shortDescr lb_ = ttyContact c <> optFullNameBadge c fullName shortDescr lb_ + +optFullNameBadge :: ContactName -> Text -> Maybe Text -> Maybe LocalBadge -> StyledString +optFullNameBadge c fullName shortDescr lb_ = case activeBadge lb_ of + Nothing -> optFullName c fullName shortDescr + Just (color, typeWord) -> " (" <> nameInner <> badgeStar color <> plain (" " <> typeWord) <> ")" + where + nameInner = maybe "" (\t -> plain (t <> ", ")) innerName + innerName + | T.null fullName || c == fullName = shortDescr + | otherwise = Just fullName + ttyFullContact :: Contact -> StyledString -ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr}} = - ttyFullName localDisplayName fullName shortDescr +ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName, shortDescr, localBadge}} = + ttyFullNameBadge localDisplayName fullName shortDescr localBadge ttyMember :: GroupMember -> StyledString ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName @@ -2816,7 +2869,8 @@ ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (vie ttyQuotedMember Nothing = ">" ttyFromContact :: Contact -> StyledString -ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ") +ttyFromContact ct@Contact {localDisplayName = c, profile = LocalProfile {localBadge}} = + ctIncognito ct <> ttyFrom (viewName c) <> badgeStarSep localBadge <> ttyFrom "> " ttyFromContactEdited :: Contact -> StyledString ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> [edited] ") diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs new file mode 100644 index 0000000000..90e3e9ae7a --- /dev/null +++ b/tests/BadgeTests.hs @@ -0,0 +1,142 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DisambiguateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeTests (badgeTests) where + +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import qualified Data.Aeson as J +import qualified Simplex.Messaging.Crypto as C +import Simplex.Chat.Badges +import Simplex.Messaging.Crypto.BBS +import Test.Hspec + +badgeTests :: Spec +badgeTests = do + it "full workflow: request, issue, verify credential, generate and verify proof" testFullWorkflow + it "should reject badge with tampered type" testTamperedType + it "should reject badge with tampered expiry" testTamperedExpiry + it "should reject badge with wrong server key" testWrongKey + it "should report a key index missing from configured keys" testUnknownKeyIdx + it "should compute badge status correctly" testExpiryCheck + it "should treat lifetime badges as always active" testLifetimeBadge + it "should accept unknown badge types" testUnknownBadgeType + it "credential serializes to a paste-able token and back" testCredentialSerialization + +proofOf :: BadgeProof -> BBSProof +proofOf (BadgeProof _ _ p _) = p + +proofInfo :: BadgeProof -> BadgeInfo +proofInfo (BadgeProof _ _ _ i) = i + +testKeyIdx :: Int +testKeyIdx = 1 + +keysFor :: BBSPublicKey -> Map Int BBSPublicKey +keysFor = M.singleton testKeyIdx + +testFullWorkflow :: IO () +testFullWorkflow = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let req = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just futureTime, badgeExtra = ""}} + Just vreq <- verifyPayment (BPRedeemCode "TEST") req + Right cred <- issueBadge testKeyIdx sk vreq + let BadgeCredential idx mk' _ _ = cred + idx `shouldBe` testKeyIdx + mk' `shouldBe` mk + verifyCredential pk cred >>= (`shouldBe` True) + Right badge <- generateBadgeProof pk cred (BBSPresHeader "nonce-1") + -- the proof inherits the credential's key index, so receivers find the right key + let BadgeProof {badgeKeyIdx} = badge + badgeKeyIdx `shouldBe` testKeyIdx + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + Right badge2 <- generateBadgeProof pk cred (BBSPresHeader "nonce-2") + verifyBadge (keysFor pk) badge2 >>= (`shouldBe` Just True) + proofOf badge `shouldNotBe` proofOf badge2 + +testTamperedType :: IO () +testTamperedType = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeType = BTLegend}) >>= (`shouldBe` Just False) + +testTamperedExpiry :: IO () +testTamperedExpiry = do + (pk, BadgeProof idx ph p info) <- issueBadgeProof BTSupporter (Just futureTime) + verifyBadge (keysFor pk) (BadgeProof idx ph p info {badgeExpiry = Just pastTime}) >>= (`shouldBe` Just False) + +testWrongKey :: IO () +testWrongKey = do + (_, badge) <- issueBadgeProof BTSupporter (Just futureTime) + Right (pk2, _) <- bbsKeyGen + verifyBadge (keysFor pk2) badge >>= (`shouldBe` Just False) + +testUnknownKeyIdx :: IO () +testUnknownKeyIdx = do + (pk, badge) <- issueBadgeProof BTSupporter (Just futureTime) + -- a key index not in the configured keys cannot be verified at all (Nothing) + verifyBadge (M.singleton (testKeyIdx + 1) pk) badge >>= (`shouldBe` Nothing) + +testExpiryCheck :: IO () +testExpiryCheck = do + now <- getCurrentTime + let info expiry = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""} + futureInfo = info (Just futureTime) + mkBadgeStatus now (Just True) futureInfo `shouldBe` BSActive + mkBadgeStatus now (Just True) (info (Just (addUTCTime (-nominalDay) now))) `shouldBe` BSExpired + mkBadgeStatus now (Just True) (info (Just pastTime)) `shouldBe` BSExpiredOld + mkBadgeStatus now (Just False) futureInfo `shouldBe` BSFailed + mkBadgeStatus now Nothing futureInfo `shouldBe` BSUnknownKey + +testLifetimeBadge :: IO () +testLifetimeBadge = do + now <- getCurrentTime + (pk, badge) <- issueBadgeProof BTInvestor Nothing + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + mkBadgeStatus now (Just True) (proofInfo badge) `shouldBe` BSActive + +testUnknownBadgeType :: IO () +testUnknownBadgeType = do + (pk, badge) <- issueBadgeProof (BTUnknown "future_type") (Just futureTime) + verifyBadge (keysFor pk) badge >>= (`shouldBe` Just True) + +testCredentialSerialization :: IO () +testCredentialSerialization = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let mkCred expiry = do + Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}}) + pure cred + dated <- mkCred (Just futureTime) + lifetime <- mkCred Nothing + J.eitherDecode (J.encode dated) `shouldBe` Right dated + J.eitherDecode (J.encode lifetime) `shouldBe` Right lifetime + -- a decoded credential still verifies against the issuing key + case J.eitherDecode (J.encode dated) of + Right cred -> verifyCredential pk cred >>= (`shouldBe` True) + Left e -> expectationFailure e + +-- Helpers + +futureTime :: UTCTime +futureTime = posixSecondsToUTCTime 4102444800 -- 2099-12-31 + +pastTime :: UTCTime +pastTime = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +issueBadgeProof :: BadgeType -> Maybe UTCTime -> IO (BBSPublicKey, BadgeProof) +issueBadgeProof bt expiry = do + Right (pk, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let vreq = VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = bt, badgeExpiry = expiry, badgeExtra = ""}} + Right cred <- issueBadge testKeyIdx sk vreq + Right badge <- generateBadgeProof pk cred (BBSPresHeader "test-nonce") + pure (pk, badge) diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index f56a4d803d..051ee6b304 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -33,7 +33,7 @@ withBroadcastBot opts test = bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile -broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} +broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts mkBotOpts ps publishers = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 7fdd34061f..a3a48e7d29 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -96,7 +96,7 @@ directoryServiceTests = do it "should update subscriber count periodically" testLinkCheckUpdatesCount directoryProfile :: Profile -directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} +directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing} mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 2502f3e262..0e2052b259 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -18,11 +19,18 @@ import Control.Monad.Except import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import Data.Time.Format (defaultTimeLocale, formatTime) +import qualified Data.Map.Strict as M +import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatHooks (..), defaultChatHooks, mkStoreCxt) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (currentChatVersion) import Simplex.Chat.Store.Shared (createContact) import Simplex.Chat.Types (ConnStatus (..), Profile (..), GroupRejectionReason (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite @@ -40,6 +48,13 @@ chatProfileTests = do it "update user profile and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage it "use multiword profile names" testMultiWordProfileNames + it "present supporter badge to contacts" testUserBadgeBroadcast + it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect + it "supporter badge sent to member joining via group link" testUserBadgeGroupLink + it "expired supporter badge shows as expired" testUserBadgeExpired + it "long-expired supporter badge is not presented" testUserBadgeExpiredOld + it "incognito connection does not carry supporter badge" testUserBadgeIncognito + it "supporter badge sent to contact connecting via address" testUserBadgeContactAddress describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "retry connecting via contact link" testRetryConnectingViaContactLink @@ -185,6 +200,210 @@ testUpdateProfile = bob <## "use @cat to send messages" ] +-- the test issuer key under index 1 in the test config +testBadgeKeys :: BBSPublicKey -> M.Map Int BBSPublicKey +testBadgeKeys = M.singleton 1 + +-- issue a supporter badge credential with the given expiry (test issuer) +issueTestBadge :: BBSSecretKey -> Maybe UTCTime -> IO BadgeCredential +issueTestBadge sk badgeExpiry = do + drg <- C.newRandom + mk <- generateMasterKey drg + let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry, badgeExtra = ""} + Just vreq <- verifyPayment (BPRedeemCode "TEST") BadgeRequest {masterKey = mk, badgeInfo = info} + Right cred <- issueBadge 1 sk vreq + pure cred + +-- the same single-line JSON `simplex-chat badge sign` prints, pasted into the app +addTestBadge :: HasCallStack => TestCC -> BadgeCredential -> IO () +addTestBadge cc cred = do + cc ##> ("/badge add " <> T.unpack (encodeJSON cred)) + cc <## "ok" + +testUserBadgeBroadcast :: HasCallStack => TestParams -> IO () +testUserBadgeBroadcast ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + connectUsers alice bob + addTestBadge alice =<< issueTestBadge sk Nothing + -- own badge is shown (add succeeded) + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + -- the badge XInfo is delivered in order before this message, so the contact has stored it + alice #> "@bob hi" + bob <# "alice *> hi" + +testUserBadgeOnConnect :: HasCallStack => TestParams -> IO () +testUserBadgeOnConnect ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- a contact connecting after the badge is attached receives it in the connection handshake + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeGroupLink :: HasCallStack => TestParams -> IO () +testUserBadgeGroupLink ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + 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" + ] + -- the host's profile (x.grp.link.mem) is sent over the same connection as group messages, + -- so receiving a message guarantees the badge arrived + alice #> "#team hello" + bob <# "#team alice> hello" + -- no prior contact: the host's badge arrives via the group link handshake + bob ##> "/i #team alice" + bob <## "group ID: 1" + bob <##. "member ID: " + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "connection not verified, use /code command to see security code" + bob <## currentChatVRangeInfo + +testUserBadgeContactAddress :: HasCallStack => TestParams -> IO () +testUserBadgeContactAddress ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + alice ##> "/ad" + (shortLink, cLink) <- getContactLinks alice True + -- the address link data carries the badge proof; the connect plan returns it verified, without crypto + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldContain` "\"proof\":" + sLinkData `shouldContain` "\"localBadge\":{\"badge\":{\"badgeType\":\"supporter\"" + sLinkData `shouldContain` "\"status\":\"active\"" + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "no expiry" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeExpired :: HasCallStack => TestParams -> IO () +testUserBadgeExpired ps = do + Right (pk, sk) <- bbsKeyGen + -- expired recently (within 31 days), so the badge is still presented and shown as expired + expiry <- addUTCTime (-2 * nominalDay) <$> getCurrentTime + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk expiry) ps + where + test sk expiry alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just expiry) + -- expired badge: no star + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - expired" + bob <## ("expires " <> formatTime defaultTimeLocale "%Y-%m-%d" expiry) + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeExpiredOld :: HasCallStack => TestParams -> IO () +testUserBadgeExpiredOld ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk (Just pastDate) + -- a badge that expired over a month ago is not presented to contacts at all + connectUsers alice bob + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + pastDate = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + +testUserBadgeIncognito :: HasCallStack => TestParams -> IO () +testUserBadgeIncognito ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk Nothing + -- an incognito identity must not carry the badge + bob ##> "/connect" + inv <- getInvitation bob + alice ##> ("/connect incognito " <> inv) + alice <## "confirmation sent!" + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob to print out this incognito profile again" + ] + bob ##> ("/i " <> aliceIncognito) + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ @@ -279,7 +498,7 @@ testMultiWordProfileNames = aliceProfile' = baseProfile {displayName = "Alice Jones"} bobProfile' = baseProfile {displayName = "Bob James"} cathProfile' = baseProfile {displayName = "Cath Johnson"} - baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs} + baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} testUserContactLink :: HasCallStack => TestParams -> IO () testUserContactLink = @@ -1187,13 +1406,13 @@ testPlanAddressContactViaAddress = Left _ -> error "error parsing contact link" Right cReq -> do let profile = aliceProfile {contactLink = Just cReq} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1208,7 +1427,7 @@ testPlanAddressContactViaAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api @@ -1249,13 +1468,13 @@ testPlanAddressContactViaShortAddress = Left _ -> error "error parsing contact link" Right shortLink -> do let profile = aliceProfile {contactLink = Just shortLink} - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> "/delete @alice" bob <## "alice: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> sLink) @@ -1270,7 +1489,7 @@ testPlanAddressContactViaShortAddress = alice ##> "/delete @bob" alice <## "bob: contact is deleted" - void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile + void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> let TestCC {chatController = ChatController {config}} = bob in runExceptT $ createContact db (mkStoreCxt config) user profile bob @@@ [("@alice", "")] -- GUI api diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 27c36568ec..b83b79c3a9 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -85,7 +85,7 @@ chatRelayProfile :: Profile chatRelayProfile = mkProfile "relay" "Relay" Nothing mkProfile :: T.Text -> T.Text -> Maybe ImageData -> Profile -mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs} +mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing} it :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expectation)) it name test = diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index d57411a598..bc0cc30a78 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -32,8 +32,10 @@ import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures import Simplex.Chat +import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) +import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -81,6 +83,8 @@ mobileTests = do describe "Parsers" $ do it "should parse server address" testChatParseServer it "should parse and sanitize URI" testChatParseUri + describe "Badges" $ do + it "should generate key and issue badge via C API, verify credential" testBadgeKeygenIssueCApi noActiveUser :: LB.ByteString noActiveUser = @@ -308,6 +312,25 @@ testChatParseUri :: TestParams -> IO () testChatParseUri _ = do pure () +-- Generate a server keypair and issue a badge credential via the C FFI, +-- constructing the request from the typed records, then verify the issued +-- credential's BBS signature on the Haskell side. +testBadgeKeygenIssueCApi :: TestParams -> IO () +testBadgeKeygenIssueCApi _ = do + g <- C.newRandom + IssuerKeyPair {publicKey, secretKey} <- ffiResult =<< (peekCString =<< cChatBadgeKeygen) + mk <- generateMasterKey g + let req = BadgeIssueReq {badgeKeyIdx = 1, secretKey, request = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Nothing, badgeExtra = ""}}} + cred <- ffiResult =<< (peekCString =<< cChatBadgeIssue =<< newCString (LB.unpack (J.encode req))) + verifyCredential publicKey cred `shouldReturn` True + +-- Decode an FFI `BadgeResult` envelope, returning the result or failing on error. +ffiResult :: FromJSON r => String -> IO r +ffiResult s = case J.eitherDecode (LB.pack s) of + Right (BadgeResult r) -> pure r + Right (BadgeError e) -> error $ "badge FFI error: " <> show e + Left e -> error $ "badge FFI decode failed: " <> e <> " in " <> s + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index aef41e90d2..10f8808015 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -104,7 +104,7 @@ testGroupPreferences :: Maybe GroupPreferences testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, commands = Nothing} testProfile :: Profile -testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences} +testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing} testGroupProfile :: GroupProfile testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} @@ -218,7 +218,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XInfo testProfile it "x.info with empty full name" $ "{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" - #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences} + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing} it "x.contact with xContactId" $ "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing Nothing diff --git a/tests/Test.hs b/tests/Test.hs index 639708441e..874428bc1f 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -11,6 +11,7 @@ import ChatTests.DBUtils import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System +import BadgeTests import JSONTests import MarkdownTests import MemberRelationsTests @@ -60,6 +61,7 @@ main = do #endif around tmpBracket $ describe "WebRTC encryption" webRTCTests #endif + describe "Supporter badges" badgeTests describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests describe "Member relations" memberRelationsTests From 17fcb435760bba0b689642dcda91d98bf60a5487 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 15 Jun 2026 22:29:07 +0100 Subject: [PATCH 14/81] core: 6.5.5.0 (simplexmq 6.5.4.0) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index d3b9eeffa5..d6151cc620 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9f9b6c8e88524fb5fd063f47617a679ea53ac7c0 + tag: 376d6a261a1074717aed65ad97bb6f2a9532011b source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ac230b7af1..150a2a4e6f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9f9b6c8e88524fb5fd063f47617a679ea53ac7c0" = "01jdjndx0h2ardzi9dd21q0n36lvwbdkhp7nzdrz01c3hh0br9bd"; + "https://github.com/simplex-chat/simplexmq.git"."376d6a261a1074717aed65ad97bb6f2a9532011b" = "1j83kzjcgjr7ngbamby96r90yal80c6kv79l9shy05mppmp73f4y"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 3a1a8ff24b..20b71a052d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.4.1 +version: 6.5.5.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 732acf0474c22cf7cff228f9ca3877984882b882 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 16 Jun 2026 06:53:55 +0100 Subject: [PATCH 15/81] desktop: shorter "close to tray" setting --- .../common/src/commonMain/resources/MR/base/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ecca74fae2..d6d31dd4d1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3103,8 +3103,8 @@ Quit SimpleX SimpleX SimpleX — %d unread - Minimize to tray when closing window - Keep SimpleX running in the background to receive messages. + Close to tray + Runs in background to receive messages %s supports SimpleX Chat. %1$s supported SimpleX Chat. The badge expired on %2$s. You can support SimpleX starting from v7 of the app. From 43904dd0dccc46ade1e7413aa10e5ebf2eac1b3b Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:27 +0400 Subject: [PATCH 16/81] desktop: authenticate call server websocket (#7076) * Authenticate desktop call websocket * call: patch ui.ts source for ws token and add server auth integration test --------- Co-authored-by: Paul Bottinelli --- .../resources/assets/www/desktop/ui.js | 4 +- .../common/views/call/CallView.desktop.kt | 43 ++++++++++-- .../chat/simplex/app/CallServerAuthTest.kt | 70 +++++++++++++++++++ .../chat/simplex/app/CallServerTokenTest.kt | 29 ++++++++ .../simplex-chat-webrtc/src/desktop/ui.ts | 4 +- 5 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt 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 7c0836960c..e6828817ee 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 @@ -3,7 +3,7 @@ useWorker = typeof window.Worker !== "undefined"; isDesktop = true; // Create WebSocket connection. -const socket = new WebSocket(`ws://${location.host}`); +const socket = new WebSocket(`ws://${location.host}${location.search}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); sendMessageToNative = (msg) => { @@ -192,4 +192,4 @@ function updateCallInfoView(state, description) { document.getElementById("state").innerText = state; document.getElementById("description").innerText = description; } -//# sourceMappingURL=ui.js.map \ No newline at end of file +//# sourceMappingURL=ui.js.map 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 20fe6a48a3..75782d75d7 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 @@ -18,10 +18,12 @@ import org.nanohttpd.protocols.http.response.Status import org.nanohttpd.protocols.websockets.* import java.io.IOException import java.net.BindException -import java.net.URI +import java.security.SecureRandom +import java.util.Base64 private const val SERVER_HOST = "localhost" private const val SERVER_PORT = 50395 +private const val CALL_SERVER_TOKEN_BYTES = 32 val connections = ArrayList() // Spec: spec/services/calls.md#ActiveCallView @@ -153,14 +155,15 @@ private fun SendStateUpdates() { @Composable fun WebRTCController(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val uriHandler = LocalUriHandler.current + val token = remember { newCallServerToken() } val endCall = { val call = chatModel.activeCall.value if (call != null) withBGApi { chatModel.callManager.endCall(call) } } val server = remember { - startServer(onResponse).apply { + startServer(onResponse, token = token).apply { try { - uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") + uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/?token=$token") } catch (e: Exception) { Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") AlertManager.shared.showAlertMsg( @@ -208,7 +211,11 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } -fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { +fun startServer( + onResponse: (WVAPIMessage) -> Unit, + port: Int = SERVER_PORT, + token: String = newCallServerToken(), +): NanoWSD { val server = object: NanoWSD(SERVER_HOST, port) { override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) @@ -227,8 +234,18 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na override fun handle(session: IHTTPSession): Response { return when { - session.headers["upgrade"] == "websocket" -> super.handle(session) - session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") + session.headers["upgrade"] == "websocket" -> + if (hasValidCallServerToken(session.parameters, token)) { + super.handle(session) + } else { + unauthorizedResponse() + } + session.uri.contains("/simplex/call/") -> + if (hasValidCallServerToken(session.parameters, token)) { + resourcesToResponse("/desktop/call.html") + } else { + unauthorizedResponse() + } else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL")) } } @@ -239,11 +256,23 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na if (port == 0) throw e Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") server.stop() - return startServer(onResponse, port = 0) + return startServer(onResponse, port = 0, token = token) } return server } +internal fun newCallServerToken(): String { + val bytes = ByteArray(CALL_SERVER_TOKEN_BYTES) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +} + +internal fun hasValidCallServerToken(parameters: Map>, token: String): Boolean = + token.isNotEmpty() && parameters["token"]?.any { it == token } == true + +private fun unauthorizedResponse(): Response = + newFixedLengthResponse(Status.UNAUTHORIZED, "text/plain", "Unauthorized") + class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) { override fun onOpen() { connections.add(this) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt new file mode 100644 index 0000000000..800c69f617 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt @@ -0,0 +1,70 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.startServer +import java.net.Socket +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +// Integration test for the desktop call server's token gate (the handle() enforcement), +// which the unit-level CallServerTokenTest does not exercise. +class CallServerAuthTest { + private val token = "integration-test-token" + // port = 0 binds a random free port, avoiding a clash with a real call server on SERVER_PORT + private val server = startServer(onResponse = {}, port = 0, token = token) + private val port get() = server.listeningPort + + @AfterTest + fun tearDown() = server.stop() + + @Test + fun testWebSocketUpgradeRejectedWithoutToken() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/"))) + } + + @Test + fun testWebSocketUpgradeRejectedWithWrongToken() { + assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong"))) + } + + @Test + fun testWebSocketUpgradeAcceptedWithToken() { + assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token"))) + } + + @Test + fun testCallPageRejectedWithoutToken() { + assertEquals(401, requestStatus(get(path = "/simplex/call/"))) + } + + @Test + fun testCallPagePassesAuthGateWithToken() { + // Resource serving may differ in the test classpath, so assert only that the auth gate was passed (not 401) + assertNotEquals(401, requestStatus(get(path = "/simplex/call/?token=$token"))) + } + + private fun get(path: String): List = listOf("GET $path HTTP/1.1", "Host: localhost:$port") + + private fun webSocketUpgrade(path: String): List = + listOf( + "GET $path HTTP/1.1", + "Host: localhost:$port", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + ) + + // Sends a raw HTTP request and returns the response status code from the status line. + private fun requestStatus(requestLines: List): Int = + Socket("localhost", port).use { socket -> + socket.soTimeout = 5000 + socket.getOutputStream().apply { + write((requestLines.joinToString("\r\n") + "\r\n\r\n").toByteArray()) + flush() + } + val statusLine = socket.getInputStream().bufferedReader().readLine() ?: error("no response from call server") + statusLine.split(" ")[1].toInt() + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt new file mode 100644 index 0000000000..dc729b1ec2 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt @@ -0,0 +1,29 @@ +package chat.simplex.app + +import chat.simplex.common.views.call.hasValidCallServerToken +import chat.simplex.common.views.call.newCallServerToken +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CallServerTokenTest { + @Test + fun testCallServerTokenRequiresExactTokenParameter() { + val token = "secret" + + assertTrue(hasValidCallServerToken(mapOf("token" to listOf(token)), token)) + assertFalse(hasValidCallServerToken(mapOf("token" to listOf("wrong")), token)) + assertFalse(hasValidCallServerToken(mapOf("x-token" to listOf(token)), token)) + assertFalse(hasValidCallServerToken(mapOf("token" to listOf(token)), "")) + } + + @Test + fun testCallServerTokenIsUrlSafe() { + val token = newCallServerToken() + + assertTrue(token.length >= 40) + assertFalse(token.contains("+")) + assertFalse(token.contains("/")) + assertFalse(token.contains("=")) + } +} diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts index eac659a17a..862c727bd5 100644 --- a/packages/simplex-chat-webrtc/src/desktop/ui.ts +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -2,8 +2,8 @@ useWorker = typeof window.Worker !== "undefined" isDesktop = true -// Create WebSocket connection. -const socket = new WebSocket(`ws://${location.host}`) +// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server. +const socket = new WebSocket(`ws://${location.host}${location.search}`) socket.addEventListener("open", (_event) => { console.log("Opened socket") From adb3fb8cb2c0ffbfe9f04772964ab77f553fceb0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 16 Jun 2026 14:36:55 +0100 Subject: [PATCH 17/81] core: render web previews for channels (#7029) * plan: web previews for channels * types for recipient side to support channel web previews and domain names * fix * migrations * update schema and api types * update schema * rename migrations * core: render channel preview data * core: render channel preview data in relays * website: use cpp to inject JS functions * JSC files * remove directory.js * channel preview renderer * Revert "cli: fix redraw slowness (#6735)" This reverts commit b801d77c74f0b688000e0bc2194e835bd1d1965e. * sample channel page * default avatar * rename options * better layout * layout * images * some fixes * tails * markdown colors * image sizes * reactions * fix reactions * fewer avatars * forward icon * command to change group access parameters * view public group access changes in CLI * media metadata color * ios: group web access ui * update ui * add init * kotlin, labels * update page * update relay base URL * fix * ios update channel web page info * update kotlin layout * use cards * update layout * use domains for relay data, path is fixed * update embed code * fix bots api * include only history items and senders * update preview JS/HTML * show different error if link is different * remove stale json files * better layout * layout fixes * improve layout * improve layout * update embed code * web cta * better layout * buttons * layout * paddings * desktop cta * desktop cta * cta layout * fonts * paddings * paddings * more paddings * copy link * read more * hide avatar and placeholder when all messages are from channel * color scheme * fix color * improve * layout * welcome message * dark mode colors * padding * font size * overscroll * font * logo on button * better join * buttons * refactor * another logo * text * desktop button * button text * center * fix svg * padding * smaller gap * render channel on any message changes etc * fixes * atomic file updates, escape attributes * fix tests * more tests * more efficient rendering * improve security * sanitize links, include mentioned members * schema * fixes * improve rendering * fix showing correct subscribers count * fix member names --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .gitignore | 5 +- .../Chat/Group/ChannelWebAccessView.swift | 169 ++ .../Views/Chat/Group/GroupChatInfoView.swift | 17 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 7 + .../views/chat/group/ChannelWebPageView.kt | 186 ++ .../views/chat/group/GroupChatInfoView.kt | 22 + .../common/views/helpers/TextEditor.kt | 22 + .../commonMain/resources/MR/base/strings.xml | 14 + bots/src/API/Docs/Commands.hs | 1 + simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 7 +- src/Simplex/Chat/Controller.hs | 41 + src/Simplex/Chat/Library/Commands.hs | 40 + src/Simplex/Chat/Library/Internal.hs | 5 +- src/Simplex/Chat/Library/Subscriber.hs | 17 +- src/Simplex/Chat/Mobile.hs | 1 + src/Simplex/Chat/Options.hs | 44 +- src/Simplex/Chat/Protocol.hs | 7 +- src/Simplex/Chat/Store/Groups.hs | 39 + src/Simplex/Chat/Store/Messages.hs | 19 + src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20260601_relay_sent_web_domain.hs | 19 + .../Store/Postgres/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20260601_relay_sent_web_domain.hs | 18 + .../SQLite/Migrations/agent_query_plans.txt | 9 - .../SQLite/Migrations/chat_query_plans.txt | 61 + .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Types.hs | 9 +- src/Simplex/Chat/View.hs | 22 +- src/Simplex/Chat/Web.hs | 430 +++++ tests/Bots/DirectoryTests.hs | 4 +- tests/ChatClient.hs | 7 +- tests/ChatTests/ChatRelays.hs | 252 +++ tests/ProtocolTests.hs | 8 +- website/.eleventy.js | 2 +- website/channel_sample.html | 28 + website/src/js/channel-preview.jsc | 1548 +++++++++++++++++ .../src/js/{directory.js => directory.jsc} | 148 +- website/src/js/simplex-lib.jsc | 156 ++ website/web.sh | 4 + 42 files changed, 3234 insertions(+), 175 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs create mode 100644 src/Simplex/Chat/Web.hs create mode 100644 website/channel_sample.html create mode 100644 website/src/js/channel-preview.jsc rename website/src/js/{directory.js => directory.jsc} (77%) create mode 100644 website/src/js/simplex-lib.jsc diff --git a/.gitignore b/.gitignore index 7bd3d04e59..035d24c6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,10 @@ website/translations.json website/src/img/images/ website/src/images/ website/src/js/lottie.min.js -website/src/js/ethers* +website/src/js/ethers.* +website/src/js/directory.js +website/src/js/channel-preview.js +website/src/js/simplex-lib.js website/src/file-assets/ website/src/link-images/ website/src/privacy.md diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift new file mode 100644 index 0000000000..dd46b7a117 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift @@ -0,0 +1,169 @@ +// +// ChannelWebAccessView.swift +// SimpleX (iOS) +// +// Created by simplex.chat on 31/05/2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelWebAccessView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) var dismiss: DismissAction + @Binding var groupInfo: GroupInfo + @State private var webPage: String + @State private var allowEmbedding: Bool + @State private var saving = false + @State private var groupRelays: [GroupRelay] = [] + + init(groupInfo: Binding) { + _groupInfo = groupInfo + let access = groupInfo.wrappedValue.groupProfile.publicGroup?.publicGroupAccess + _webPage = State(initialValue: access?.groupWebPage ?? "") + _allowEmbedding = State(initialValue: access?.allowEmbedding ?? false) + } + + var body: some View { + List { + if let code = embedCode { + webpageInfo("Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.") + + Section { + ScrollView { + Text(code) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + .frame(maxHeight: 88) + Button { + UIPasteboard.general.string = code + } label: { + Label("Copy code", systemImage: "doc.on.doc") + } + } header: { + Text("Webpage code") + } footer: { + Text("Add this code to your webpage. It will display the preview of your channel / group.") + } + } else { + webpageInfo("Used chat relays do not support webpages.") + } + + Section { + TextField("https://", text: $webPage) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + } header: { + Text("Enter webpage URL") + } footer: { + Text("It will be shown to subscribers and used to allow loading the preview.") + } + + Section { + Toggle("Allow anyone to embed", isOn: $allowEmbedding) + } footer: { + Text(allowEmbedding ? "Any webpage can show the preview." : "Only your page above can show the preview.") + } + + Section { + Button { + saveAccess() + } label: { + HStack { + Text(groupInfo.isChannel ? "Save and notify subscribers" : "Save and notify members") + if saving { Spacer(); ProgressView() } + } + } + .disabled(!hasChanges || saving) + } + } + .modifier(ThemedBackground(grouped: true)) + .onAppear { + Task { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { groupRelays = relays } + } + } + .onDisappear { + if hasChanges { + showAlert( + title: NSLocalizedString("Save webpage settings?", comment: "alert title"), + message: NSLocalizedString("Webpage settings were changed. If you save, the updated settings will be sent to subscribers.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: saveAccess, + cancelButton: true + ) + } + } + } + + private func webpageInfo(_ text: LocalizedStringKey) -> some View { + Section { + Text(text).foregroundColor(theme.colors.secondary) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 0, trailing: 16)) + } + + private var hasChanges: Bool { + let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + let currentWebPage = access?.groupWebPage ?? "" + let currentEmbedding = access?.allowEmbedding ?? false + return webPage != currentWebPage || allowEmbedding != currentEmbedding + } + + private var relayDomains: [String] { + groupRelays.compactMap { $0.relayCap.webDomain } + } + + private var embedCode: String? { + if let pg = groupInfo.groupProfile.publicGroup, + !relayDomains.isEmpty { + """ +
+ + """ + } else { + nil + } + } + + private func saveAccess() { + saving = true + Task { + do { + var gp = groupInfo.groupProfile + if var pg = gp.publicGroup { + let trimmedPage = webPage.trimmingCharacters(in: .whitespacesAndNewlines) + let existingAccess = pg.publicGroupAccess + pg.publicGroupAccess = PublicGroupAccess( + groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage, + groupDomain: existingAccess?.groupDomain, + domainWebPage: existingAccess?.domainWebPage ?? false, + allowEmbedding: allowEmbedding + ) + gp.publicGroup = pg + } + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + saving = false + } + } catch { + logger.error("ChannelWebAccessView apiUpdateGroup error: \(responseError(error))") + await MainActor.run { saving = false } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 26aedb2541..b62939fd36 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -244,6 +244,12 @@ struct GroupChatInfoView: View { } } + if groupInfo.useRelays && groupInfo.isOwner { + Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) { + channelWebAccessButton() + } + } + if developerTools { Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { infoRow("Local name", chat.chatInfo.localDisplayName) @@ -657,6 +663,17 @@ struct GroupChatInfoView: View { } } + private func channelWebAccessButton() -> some View { + let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage" + return NavigationLink { + ChannelWebAccessView(groupInfo: $groupInfo) + .navigationBarTitle(title) + .navigationBarTitleDisplayMode(.large) + } label: { + Label(title, systemImage: "globe") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 20dfbba6ee..c5dc039d8d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */; }; 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -549,6 +550,7 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelWebAccessView.swift; sourceTree = ""; }; 6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -1178,6 +1180,7 @@ 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, + E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */, 6495D7072F48D0000060512B /* AddGroupRelayView.swift */, ); path = Group; @@ -1640,6 +1643,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, + E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */, 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 6b757e795f..56f92cfc0b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2614,6 +2614,13 @@ public enum GroupType: Codable, Hashable { } public struct PublicGroupAccess: Codable, Hashable { + public init(groupWebPage: String? = nil, groupDomain: String? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) { + self.groupWebPage = groupWebPage + self.groupDomain = groupDomain + self.domainWebPage = domainWebPage + self.allowEmbedding = allowEmbedding + } + public var groupWebPage: String? public var groupDomain: String? public var domainWebPage: Bool = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt new file mode 100644 index 0000000000..98067e49dd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -0,0 +1,186 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +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.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* + +@Composable +fun ChannelWebPageView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit +) { + val isChannel = groupInfo.isChannel + val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + val webPage = rememberSaveable { mutableStateOf(access?.groupWebPage ?: "") } + val allowEmbedding = rememberSaveable { mutableStateOf(access?.allowEmbedding ?: false) } + val groupRelays = remember { mutableStateListOf() } + + val dataUnchanged = webPage.value.trim() == (access?.groupWebPage ?: "") && + allowEmbedding.value == (access?.allowEmbedding ?: false) + + val save: () -> Unit = { + withBGApi { + val trimmedPage = webPage.value.trim() + val newAccess = PublicGroupAccess( + groupWebPage = trimmedPage.ifEmpty { null }, + groupDomain = access?.groupDomain, + domainWebPage = access?.domainWebPage ?: false, + allowEmbedding = allowEmbedding.value + ) + val gp = groupInfo.groupProfile.copy( + publicGroup = groupInfo.groupProfile.publicGroup?.copy(publicGroupAccess = newAccess) + ) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, gp, isChannel) + if (gInfo != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, gInfo) + } + close() + } + } + } + + val closeWithAlert = { + if (dataUnchanged) { + close() + } else { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.save_preferences_question), + confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members), + dismissText = generalGetString(MR.strings.exit_without_saving), + onConfirm = save, + onDismiss = close, + ) + } + } + + LaunchedEffect(Unit) { + val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId) + groupRelays.clear() + groupRelays.addAll(relays) + } + + BackHandler(onBack = closeWithAlert) + ModalView(close = closeWithAlert, cardScreen = true) { + ChannelWebPageLayout( + isChannel = isChannel, + webPage = webPage, + allowEmbedding = allowEmbedding, + groupRelays = groupRelays, + groupInfo = groupInfo, + dataUnchanged = dataUnchanged, + save = save + ) + } +} + +@Composable +private fun ChannelWebPageLayout( + isChannel: Boolean, + webPage: MutableState, + allowEmbedding: MutableState, + groupRelays: List, + groupInfo: GroupInfo, + dataUnchanged: Boolean, + save: () -> Unit +) { + val clipboard = LocalClipboardManager.current + ColumnWithScrollBar { + AppBarTitle(stringResource(if (isChannel) MR.strings.channel_webpage else MR.strings.group_webpage)) + + val embedCode = embedCode(groupRelays, groupInfo) + if (embedCode != null) { + SectionTextFooter(stringResource(MR.strings.webpage_info)) + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.webpage_code)) { + SectionItemView { + Text( + embedCode, + style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + SectionItemView({ + clipboard.setText(AnnotatedString(embedCode)) + showToast(generalGetString(MR.strings.copied)) + }) { + Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.copy_code), color = MaterialTheme.colors.primary) + } + } + SectionTextFooter(stringResource(MR.strings.webpage_code_footer)) + } else { + SectionTextFooter(stringResource(MR.strings.relays_no_web_support)) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.enter_webpage_url)) { + PlainTextEditor(webPage, placeholder = stringResource(MR.strings.web_page_url_placeholder)) + } + SectionTextFooter(stringResource(MR.strings.webpage_url_footer)) + SectionDividerSpaced() + + SectionView { + PreferenceToggle(stringResource(MR.strings.allow_anyone_to_embed), checked = allowEmbedding.value) { + allowEmbedding.value = it + } + } + SectionTextFooter(stringResource(if (allowEmbedding.value) MR.strings.embed_any_webpage_can_show else MR.strings.embed_only_your_page)) + SectionDividerSpaced() + + SectionView { + SectionItemView(save, disabled = dataUnchanged) { + Text( + stringResource(MR.strings.save_verb), + color = if (dataUnchanged) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + + SectionBottomSpacer() + } +} + +private fun embedCode(groupRelays: List, groupInfo: GroupInfo): String? { + val pg = groupInfo.groupProfile.publicGroup ?: return null + val relayDomains = groupRelays.mapNotNull { it.relayCap.webDomain } + if (relayDomains.isEmpty()) return null + val domains = relayDomains.joinToString(",") + return """
+""" +} 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 93c318cab5..b2c25bf06c 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 @@ -175,6 +175,9 @@ fun ModalData.GroupChatInfoView( manageGroupLink = { ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } }, + manageWebPage = { + ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) } + }, onSearchClicked = onSearchClicked, deletingItems = deletingItems ) @@ -506,6 +509,7 @@ fun ModalData.GroupChatInfoLayout( clearChat: () -> Unit, leaveGroup: () -> Unit, manageGroupLink: () -> Unit, + manageWebPage: () -> Unit, close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, onSearchClicked: () -> Unit, deletingItems: State @@ -796,6 +800,13 @@ fun ModalData.GroupChatInfoLayout( } } + if (groupInfo.useRelays && groupInfo.isOwner) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.advanced_options)) { + ChannelWebPageButton(groupInfo, manageWebPage) + } + } + if (developerTools) { SectionDividerSpaced() SectionView(title = stringResource(MR.strings.section_title_for_console)) { @@ -1209,6 +1220,16 @@ private fun ChannelLinkButton(onClick: () -> Unit) { ) } +@Composable +private fun ChannelWebPageButton(groupInfo: GroupInfo, onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_travel_explore), + stringResource(if (groupInfo.isChannel) MR.strings.channel_webpage else MR.strings.group_webpage), + onClick, + iconColor = MaterialTheme.colors.secondary + ) +} + @Composable private fun ChannelLinkQRCodeSection(groupLink: String) { val clipboard = LocalClipboardManager.current @@ -1413,6 +1434,7 @@ fun PreviewGroupChatInfoLayout() { clearChat = {}, leaveGroup = {}, manageGroupLink = {}, + manageWebPage = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index e8070b5c76..cd40585cad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -102,6 +102,28 @@ fun TextEditor( } } +@Composable +fun PlainTextEditor( + value: MutableState, + placeholder: String? = null, + singleLine: Boolean = true +) { + BasicTextField( + value = value.value, + onValueChange = { value.value = it }, + modifier = Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = 12.dp), + textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), + singleLine = singleLine, + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = { innerTextField -> + if (value.value.isEmpty() && placeholder != null) { + Text(placeholder, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) + } + innerTextField() + } + ) +} + @Serializable data class ParsedFormattedText( val formattedText: List? = 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 e943e0080a..9630c61004 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1925,6 +1925,20 @@ Welcome message Group link Channel link + Channel webpage + Group webpage + Advanced options + https:// + Allow anyone to embed + Enter webpage URL + It will be shown to subscribers and used to allow loading the preview. + Webpage code + Add this code to your webpage. It will display the preview of your channel / group. + Copy code + Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting. + Used chat relays do not support webpages. + Any webpage can show the preview. + Only your page above can show the preview. Create group link Create link Delete link? diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 003969660b..8ebd510a55 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -281,6 +281,7 @@ cliCommands = "SetGroupTimedMessages", "SetLocalDeviceName", "SetProfileAddress", + "SetPublicGroupAccess", "SetSendReceipts", "SetShowMemberMessages", "SetShowMessages", diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ae573936e7..86350acdd4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -93,6 +93,7 @@ library Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Util + Simplex.Chat.Web if !flag(client_library) exposed-modules: Simplex.Chat.Bot @@ -141,6 +142,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain else exposed-modules: Simplex.Chat.Archive @@ -301,6 +303,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at + Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index af3f98d6a6..b795ba9b9c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -128,6 +128,7 @@ defaultChatConfig = highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, + webPreviewConfig = Nothing, channelSubscriberRole = GRObserver, relayChecksInterval = 15 * 60, -- 15 minutes relayInactiveTTL = nominalDay, @@ -152,11 +153,11 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} - ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'} randomPresetServers <- chooseRandomServers presetServers' let rndSrvs = L.toList randomPresetServers operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op @@ -194,6 +195,7 @@ newChatController deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO relayGroupLinkChecksAsync <- newTVarIO Nothing + webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState chatRelayTests <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO @@ -238,6 +240,7 @@ newChatController deliveryJobWorkers, relayRequestWorkers, relayGroupLinkChecksAsync, + webPreviewState, chatRelayTests, expireCIThreads, expireCIFlags, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6a4545a380..48913af9a5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -39,6 +39,7 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import Data.Set (Set) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.String @@ -162,6 +163,7 @@ data ChatConfig = ChatConfig ciExpirationInterval :: Int64, -- microseconds deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, + webPreviewConfig :: Maybe WebPreviewConfig, channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays relayChecksInterval :: NominalDiffTime, relayInactiveTTL :: NominalDiffTime, @@ -173,6 +175,43 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +data WebPreviewConfig = WebPreviewConfig + { webDomain :: Text, + webJsonDir :: FilePath, + webCorsFile :: Maybe FilePath, + webUpdateInterval :: Int, -- seconds + webPreviewItemCount :: Int + } + +data PublishableGroup = PublishableGroup + { pgFileName :: FilePath, + pgCorsEntry :: Maybe (Text, CorsOrigin) + } + +data CorsOrigin = CorsAny | CorsOrigins [Text] + deriving (Show) + +data WebPreviewState = WebPreviewState + { publishableGroupIds :: TVar (Map Int64 PublishableGroup), + priorityRender :: TQueue Int64, + filesToRemove :: TQueue FilePath, + corsNeeded :: TVar Bool, + routinePending :: TVar (Set Int64), + wakeSignal :: TMVar (), + webPreviewWorkerAsync :: TVar (Maybe (Async ())) + } + +newWebPreviewState :: IO WebPreviewState +newWebPreviewState = do + publishableGroupIds <- newTVarIO mempty + priorityRender <- newTQueueIO + filesToRemove <- newTQueueIO + corsNeeded <- newTVarIO False + routinePending <- newTVarIO mempty + wakeSignal <- newEmptyTMVarIO + webPreviewWorkerAsync <- newTVarIO Nothing + pure WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal, webPreviewWorkerAsync} + -- | Builds the read-only context threaded through store functions from chat config. -- The single construction point, so new store-wide config (e.g. server keys) is added in one place. mkStoreCxt :: ChatConfig -> StoreCxt @@ -266,6 +305,7 @@ data ChatController = ChatController deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), + webPreviewState :: Maybe WebPreviewState, chatRelayTests :: TMap ConnId RelayTest, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, @@ -555,6 +595,7 @@ data ChatCommand | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) | ShowGroupDescription GroupName + | SetPublicGroupAccess GroupName PublicGroupAccess | CreateGroupLink GroupName GroupMemberRole | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 72cf6a412c..646377ac9c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -90,6 +90,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U +import Simplex.Chat.Web (webPreviewWorker) import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -201,6 +202,7 @@ startChatController mainApp enableSndFiles = do startCleanupManager void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users + startWebPreview users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -232,6 +234,20 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT $ runRelayGroupLinkChecks relayUser) atomically $ writeTVar relayAsync a _ -> pure () + startWebPreview users = do + let relayUsers = filter (\User {userChatRelay} -> isTrue userChatRelay) users + ChatConfig {webPreviewConfig = cfg_} <- asks config + case (relayUsers, cfg_) of + (_ : _, Just cfg) -> do + wps_ <- asks webPreviewState + forM_ wps_ $ \WebPreviewState {webPreviewWorkerAsync} -> + readTVarIO webPreviewWorkerAsync >>= \case + Nothing -> do + cc <- ask + a <- Just <$> async (liftIO $ webPreviewWorker cfg cc relayUsers) + atomically $ writeTVar webPreviewWorkerAsync a + _ -> pure () + _ -> pure () startExpireCIs user = whenM shouldExpireChats $ do startExpireCIThread user setExpireCIFlag user True @@ -3060,6 +3076,12 @@ processChatCommand cxt nm = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withFastStore (\db -> getGroupInfoByName db cxt user gName) + SetPublicGroupAccess gName access -> withUser $ \user -> do + gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfo db cxt user + case publicGroup of + Just pg -> runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}} + Nothing -> throwChatError $ CECommandError "not a public group" APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId assertUserGroupRole gInfo GRAdmin @@ -4896,6 +4918,17 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () + sendRelayCapIfNeeded cxt gInfo + sendRelayCapIfNeeded cxt gInfo = do + ChatConfig {webPreviewConfig} <- asks config + let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) + when (currentWebDomain /= sentWebDomain) $ do + owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo + let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners + unless (null capableOwners) $ do + void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) @@ -5219,6 +5252,7 @@ chatCommandP = "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), + "/public group access " *> char_ '#' *> (SetPublicGroupAccess <$> displayNameP <*> publicGroupAccessP), "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)), "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), @@ -5425,6 +5459,12 @@ chatCommandP = clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False pure UserMsgReceiptSettings {enable, clearOverrides} onOffP = ("on" $> True) <|> ("off" $> False) + publicGroupAccessP = do + groupWebPage <- optional (" web=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + groupDomain <- optional (" domain=" *> (safeDecodeUtf8 <$> A.takeTill A.isSpace)) + domainWebPage <- (" domain_page=" *> onOffP) <|> pure False + allowEmbedding <- (" embed=" *> onOffP) <|> pure False + pure PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} profileNameDescr = (,) <$> displayNameP <*> shortDescrP -- 'Help with bot':'link ','Menu of commands':[...] botCommandsP :: Parser [ChatBotCommand] diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 0595cf7c4b..325e552d44 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1046,8 +1046,9 @@ acceptRelayJoinRequestAsync cReqInvId cReqChatVRange relayLink = do - -- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions) - let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities + ChatConfig {webPreviewConfig} <- asks config + let webDomain_ = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + msg = XGrpRelayAcpt relayLink RelayCapabilities {webDomain = webDomain_} subMode <- chatReadVar subscriptionMode cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9bb3d5d2eb..b948d7727b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -47,6 +47,7 @@ import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal +import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent @@ -870,6 +871,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = else pure gInfo pure (m {memberStatus = GSMemConnected}, gInfo') toView $ CEvtUserJoinedGroup user gInfo' m' + when (isRelay membership) $ do + cc <- ask + atomically $ channelProfileUpdated cc groupId groupProfile (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' -- Create e2ee, feature and group description chat items only on first connected relay ifM @@ -1018,6 +1022,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' verifiedMsg = do (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg + cc <- ask let ctx js = DeliveryTaskContext js False checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) checkSendAsGroup asGroup_ a @@ -1074,7 +1079,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) - forM deliveryTaskContext_ $ \taskContext -> + forM deliveryTaskContext_ $ \taskContext -> do + let contentChanged :: CM () + contentChanged = atomically $ channelContentChanged cc groupId + case event of + XMsgNew {} -> contentChanged + XMsgUpdate {} -> contentChanged + XMsgDel {} -> contentChanged + XMsgReact {} -> contentChanged + XGrpInfo p' -> atomically $ channelProfileUpdated cc groupId p' + XGrpDel {} -> atomically $ channelRemoved cc groupId + _ -> pure () pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} checkSendRcpt :: [AParsedMsg] -> CM Bool checkSendRcpt aMsgs = do diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 6cf30edbd5..85074e93f4 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -261,6 +261,7 @@ mobileChatOpts dbOptions = tbqSize = 4096, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Just "", diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 08a765077f..a936f58848 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -28,7 +28,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Numeric.Natural (Natural) import Options.Applicative -import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), updateStr, versionNumber, versionString) +import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), WebPreviewConfig (..), updateStr, versionNumber, versionString) import Simplex.FileTransfer.Description (mb) import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) import Simplex.Messaging.Encoding.String @@ -66,6 +66,7 @@ data CoreChatOpts = CoreChatOpts tbqSize :: Natural, deviceName :: Maybe Text, chatRelay :: Bool, + webPreviewConfig :: Maybe WebPreviewConfig, highlyAvailable :: Bool, yesToUpMigrations :: Bool, migrationBackupPath :: Maybe FilePath, @@ -240,6 +241,46 @@ coreChatOptsP appDir defaultDbName = do ( long "relay" <> help "Run as a chat relay client" ) + webPreviewConfig <- do + webDomain_ <- + optional $ + strOption + ( long "relay-web-domain" + <> metavar "DOMAIN" + <> help "Domain for channel web previews (relay only)" + ) + webJsonDir_ <- + optional $ + strOption + ( long "relay-web-dir" + <> metavar "DIR" + <> help "Directory for channel web preview JSON files (relay only)" + ) + webCorsFile <- + optional $ + strOption + ( long "relay-web-cors-file" + <> metavar "FILE" + <> help "Path to generated Caddy CORS config file (relay only)" + ) + webUpdateInterval <- + option auto + ( long "relay-web-interval" + <> metavar "SECONDS" + <> help "Interval between web preview regeneration in seconds (relay only)" + <> value 300 + ) + webPreviewItemCount <- + option auto + ( long "relay-web-item-count" + <> metavar "COUNT" + <> help "Number of recent messages in channel web preview (relay only)" + <> value 50 + ) + pure $ case (webDomain_, webJsonDir_) of + (Just webDomain, Just webJsonDir) -> Just WebPreviewConfig {webDomain, webJsonDir, webCorsFile, webUpdateInterval, webPreviewItemCount} + (Nothing, Nothing) -> Nothing + _ -> errorWithoutStackTrace "--relay-web-domain and --relay-web-dir must both be provided" highlyAvailable <- switch ( long "ha" @@ -283,6 +324,7 @@ coreChatOptsP appDir defaultDbName = do tbqSize, deviceName, chatRelay, + webPreviewConfig, highlyAvailable, yesToUpMigrations, migrationBackupPath, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 94951d1110..4546985e52 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -83,12 +83,13 @@ import Simplex.Messaging.Version hiding (version) -- 15 - support specifying message scopes for group messages (2025-03-12) -- 16 - support short link data (2025-06-10) -- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +-- 18 - relay web capabilities (2026-05-31) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 17 +currentChatVersion = VersionChat 18 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -155,6 +156,10 @@ shortLinkDataVersion = VersionChat 16 memberSupportVoiceVersion :: VersionChat memberSupportVoiceVersion = VersionChat 17 +-- relay sends web preview capabilities to owner +relayWebCapVersion :: VersionChat +relayWebCapVersion = VersionChat 18 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a0c9dd9ed0..2ea3fa9b84 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -67,6 +67,7 @@ module Simplex.Chat.Store.Groups getGroupMembersByIndexes, getSupportScopeMembersByIndexes, getGroupModerators, + getGroupOwners, getGroupRelayMembers, getGroupMembersForExpiration, getRemovedMembersToCleanup, @@ -98,9 +99,12 @@ module Simplex.Chat.Store.Groups createRelayRequestGroup, updateRelayOwnStatusFromTo, updateRelayOwnStatus_, + getRelaySentWebDomain, + updateRelaySentWebDomain, isRelayGroupRejected, allowRelayGroup, getRelayServedGroups, + getRelayPublishableGroups, getRelayInactiveGroups, createNewContactMemberAsync, createJoiningMember, @@ -1211,6 +1215,15 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) +getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + ts <- getCurrentTime + map (toContactMember ts cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") + (userId, groupId, userContactId, GROwner) + getGroupRelayMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupRelayMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do currentTs <- getCurrentTime @@ -1650,6 +1663,14 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId) +getRelaySentWebDomain :: DB.Connection -> GroupInfo -> IO (Maybe Text) +getRelaySentWebDomain db GroupInfo {groupId} = + join <$> maybeFirstRow fromOnly (DB.query db "SELECT relay_sent_web_domain FROM groups WHERE group_id = ?" (Only groupId)) + +updateRelaySentWebDomain :: DB.Connection -> GroupInfo -> Maybe Text -> IO () +updateRelaySentWebDomain db GroupInfo {groupId} webDomain_ = + DB.execute db "UPDATE groups SET relay_sent_web_domain = ? WHERE group_id = ?" (webDomain_, groupId) + -- Flip every RSRejected row sharing the targeted group's relay_request_group_link -- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId. allowRelayGroup :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO GroupInfo @@ -1696,6 +1717,24 @@ getRelayServedGroups db cxt User {userId, userContactId} = do ) (userId, userContactId, RSAccepted, RSActive) +getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] +getRelayPublishableGroups db User {userId, userContactId} = + map toRow <$> + DB.query + db + [sql| + SELECT g.group_id, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id AND mu.contact_id = ? + WHERE g.user_id = ? AND g.relay_own_status IN (?, ?) + AND gp.public_group_id IS NOT NULL + |] + (userContactId, userId, RSAccepted, RSActive) + where + toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow) + getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 3a96756712..cf12db7ec1 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -137,6 +137,7 @@ module Simplex.Chat.Store.Messages getGroupSndStatuses, getGroupSndStatusCounts, getGroupHistoryItems, + getGroupWebPreviewItems, ) where @@ -3716,3 +3717,21 @@ getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do LIMIT ? |] (groupMemberId' m, userId, groupId, count) + +getGroupWebPreviewItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] +getGroupWebPreviewItems db user@User {userId} g@GroupInfo {groupId} count = do + ciIds <- + map fromOnly + <$> DB.query + db + [sql| + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + |] + (userId, groupId, count) + reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 862a93f00d..20acf0b602 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -36,6 +36,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -71,7 +72,8 @@ schemaMigrations = ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), - ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..1b8efbcead --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260601_relay_sent_web_domain :: Text +m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Text +down_m20260601_relay_sent_web_domain = + [r| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index f015999274..89cddd48e5 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -978,7 +978,8 @@ CREATE TABLE test_chat_schema.groups ( relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, - relay_inactive_at timestamp with time zone + relay_inactive_at timestamp with time zone, + relay_sent_web_domain text ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 84860d35fe..78838c507f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -159,6 +159,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260516_supporter_badges import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at +import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -317,7 +318,8 @@ schemaMigrations = ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), - ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at) + ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs new file mode 100644 index 0000000000..922a563356 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260601_relay_sent_web_domain.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260601_relay_sent_web_domain :: Query +m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups ADD COLUMN relay_sent_web_domain TEXT; +|] + +down_m20260601_relay_sent_web_domain :: Query +down_m20260601_relay_sent_web_domain = + [sql| +ALTER TABLE groups DROP COLUMN relay_sent_web_domain; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index ee857211aa..a986773cb2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -548,15 +548,6 @@ Plan: SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) -Query: - UPDATE rcv_messages - SET receive_attempts = receive_attempts + 1 - WHERE conn_id = ? AND internal_id = ? - RETURNING receive_attempts - -Plan: -SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) - Query: DELETE FROM conn_confirmations WHERE conn_id = ? diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index e31194d151..d750be3275 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1618,6 +1618,18 @@ Plan: SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT i.chat_item_id + FROM chat_items i + WHERE i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) + Query: SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i @@ -5442,6 +5454,36 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, + g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.root_priv_key, g.root_pub_key, g.member_priv_key, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link + + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?) +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, @@ -5696,6 +5738,25 @@ Query: FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + 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.xcontact_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.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index f7bdcc1eb8..06810d6aab 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -191,7 +191,8 @@ CREATE TABLE groups( relay_request_retries INTEGER NOT NULL DEFAULT 0, relay_request_delay INTEGER NOT NULL DEFAULT 0, relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00', - relay_inactive_at TEXT, -- received + relay_inactive_at TEXT, + relay_sent_web_domain TEXT, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 4f40e3a566..f9e36a86ad 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -881,8 +881,13 @@ instance FromJSON ImageData where parseJSON = fmap ImageData . J.parseJSON instance ToJSON ImageData where - toJSON (ImageData t) = J.toJSON t - toEncoding (ImageData t) = J.toEncoding t + toJSON (ImageData t) = J.toJSON $ safeImageData t + toEncoding (ImageData t) = J.toEncoding $ safeImageData t + +safeImageData :: Text -> Text +safeImageData t + | "data:" `T.isPrefixOf` t = t + | otherwise = "" instance ToField ImageData where toField (ImageData t) = toField t diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b98d965254..cd7a5daea9 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1207,8 +1207,8 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} = ] showRelay :: GroupRelay -> StyledString -showRelay GroupRelay {groupRelayId, relayStatus} = - " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) +showRelay GroupRelay {groupRelayId, relayStatus, relayCap = RelayCapabilities {webDomain}} = + " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) <> maybe "" (\d -> ", web: " <> plain d) webDomain viewGroupRelays :: GroupInfo -> [GroupRelay] -> [StyledString] viewGroupRelays g relays = @@ -1982,10 +1982,10 @@ countactUserPrefText cup = case cup of viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> Maybe MsgSigStatus -> [StyledString] viewGroupUpdated - GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma}} - g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}} + GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma, publicGroup = pg}} + g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma', publicGroup = pg'}} m signed = do - let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated + let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated <> publicGroupAccessUpdated if null update then [] else memberUpdated <> update @@ -2010,6 +2010,18 @@ viewGroupUpdated memberAdmissionUpdated | ma == ma' = [] | otherwise = ["changed member admission rules"] + publicGroupAccessUpdated + | access == access' = [] + | otherwise = ["updated public group access:" <> viewAccess access'] + where + access = pg >>= publicGroupAccess + access' = pg' >>= publicGroupAccess + viewAccess Nothing = " removed" + viewAccess (Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}) = + maybe "" (\u -> " web=" <> plain u) groupWebPage + <> maybe "" (\d -> " domain=" <> plain d) groupDomain + <> (if domainWebPage then " domain_page=on" else "") + <> (if allowEmbedding then " embed=on" else "") viewGroupProfile :: GroupInfo -> [StyledString] viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, description, image, groupPreferences = gps}} = diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs new file mode 100644 index 0000000000..2b4fb89137 --- /dev/null +++ b/src/Simplex/Chat/Web.hs @@ -0,0 +1,430 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Simplex.Chat.Web + ( WebChannelPreview (..), + WebMessage (..), + WebMemberProfile (..), + WebFileInfo (..), + webPreviewWorker, + writeCorsConfig, + removeStaleFiles, + channelContentChanged, + channelProfileUpdated, + channelRemoved, + extractOrigin, + ) +where + +import Control.Concurrent.STM (check, flushTQueue) +import Control.Exception (SomeException, catch) +import Control.Logger.Simple +import Control.Monad (forM_, void, when) +import Control.Monad.Except (runExceptT) +import Data.Either (rights) +import Data.Int (Int64) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Text.Encoding (encodeUtf8) +import qualified Data.Map.Strict as M +import qualified Data.Set as S +import Data.Maybe (isJust, mapMaybe, maybeToList) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Messages + ( CChatItem (..), + CIDirection (..), + CIFile (..), + CIMeta (..), + CIQDirection (..), + CIQuote (..), + CIReactionCount, + ChatItem (..), + ChatType (..), + ) +import Simplex.Chat.Messages.CIContent (ciMsgContent) +import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport) +import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups) +import Simplex.Chat.Store.Messages (getGroupWebPreviewItems) +import Simplex.Chat.Store.Shared (getGroupInfo) +import Simplex.Chat.Types + ( B64UrlByteString, + GroupInfo (..), + GroupMember (..), + GroupProfile (..), + GroupSummary (..), + ImageData, + LocalProfile (..), + MemberId, + PublicGroupAccess (..), + PublicGroupProfile (..), + User (..), + ) +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Util (safeDecodeUtf8) +import qualified URI.ByteString as U +import Simplex.Messaging.Parsers (defaultJSON) +import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) +import System.FilePath (dropExtension, takeExtension, ()) +import UnliftIO.STM + +data WebFileInfo = WebFileInfo + { fileName :: String, + fileSize :: Integer + } + deriving (Show) + +data WebMemberProfile = WebMemberProfile + { memberId :: MemberId, + displayName :: Text, + image :: Maybe ImageData + } + deriving (Show) + +data WebMessage = WebMessage + { sender :: Maybe MemberId, + ts :: UTCTime, + content :: MsgContent, + formattedText :: Maybe MarkdownList, + file :: Maybe WebFileInfo, + quote :: Maybe QuotedMsg, + reactions :: [CIReactionCount], + forward :: Maybe Bool, + edited :: Bool + } + deriving (Show) + +data WebChannelPreview = WebChannelPreview + { channel :: GroupProfile, + shortDescription :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + members :: [WebMemberProfile], + subscribers :: Maybe Int64, + messages :: [WebMessage], + updatedAt :: UTCTime + } + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''WebFileInfo) + +$(JQ.deriveJSON defaultJSON ''WebMemberProfile) + +$(JQ.deriveJSON defaultJSON ''WebMessage) + +$(JQ.deriveJSON defaultJSON ''WebChannelPreview) + +webPreviewWorker :: WebPreviewConfig -> ChatController -> [User] -> IO () +webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterval} cc users = + forM_ (webPreviewState cc) $ \wps -> do + createDirectoryIfMissing True webJsonDir + initPublishableGroups wps + cleanStaleFiles wps + regenerateCors wps + seedRoutinePending wps + workerLoop wps + where + cxt = mkStoreCxt (config cc) + + workerLoop wps@WebPreviewState {priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} = do + drainRemovals + drainPriority + handleCors + renderRoutine + noRoutine <- atomically $ S.null <$> readTVar routinePending + when noRoutine waitRefresh + workerLoop wps + where + drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case + Nothing -> pure () + Just f -> do + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + drainRemovals + + -- flush the whole queue and render each group once: a burst of changes in one + -- channel enqueues its id many times, but only needs a single render + drainPriority = do + gIds <- atomically $ flushTQueue priorityRender + forM_ (S.fromList gIds) $ renderOneGroup wps + + handleCors = do + needed <- atomically $ swapTVar corsNeeded False + when needed $ regenerateCors wps + + -- render a single routine item; the main loop calls this once per iteration + renderRoutine = do + mGId <- atomically $ do + pending <- readTVar routinePending + case S.minView pending of + Nothing -> pure Nothing + Just (gId, rest) -> writeTVar routinePending rest >> pure (Just gId) + forM_ mGId $ renderOneGroup wps + + -- routine list drained: wait for the refresh timer or a change signal; only the timer + -- seeds the next full sweep, a change just returns to let the main loop service it + waitRefresh = do + delay <- registerDelay (webUpdateInterval * 1000000) + timerFired <- atomically $ + (True <$ (readTVar delay >>= check)) `orElse` (False <$ takeTMVar wakeSignal) + when timerFired $ seedRoutinePending wps + + initPublishableGroups WebPreviewState {publishableGroupIds} = do + rows <- withTransaction (chatStore cc) $ \db -> + concat <$> mapM (getRelayPublishableGroups db) users + let gIds = M.fromList [(gId, toPublishableGroup pgId access) | (gId, pgId, access) <- rows] + atomically $ writeTVar publishableGroupIds gIds + + cleanStaleFiles WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let activeFiles = S.fromList $ map pgFileName $ M.elems ids + removeStaleFiles webJsonDir activeFiles + + regenerateCors WebPreviewState {publishableGroupIds} = do + ids <- readTVarIO publishableGroupIds + let entries = mapMaybe pgCorsEntry $ M.elems ids + forM_ webCorsFile $ writeCorsConfig entries + + seedRoutinePending WebPreviewState {publishableGroupIds, routinePending} = + atomically $ M.keysSet <$> readTVar publishableGroupIds >>= writeTVar routinePending + + renderOneGroup WebPreviewState {publishableGroupIds} gId = do + publishable <- atomically $ M.member gId <$> readTVar publishableGroupIds + when publishable $ + renderOrRemoveStale `catch` \(e :: SomeException) -> + logError $ "web preview: error rendering group " <> T.pack (show gId) <> ": " <> T.pack (show e) + where + renderOrRemoveStale = do + r <- withTransaction (chatStore cc) $ \db -> + findUser $ \u -> fmap (\g -> (u, g)) <$> runExceptT (getGroupInfo db cxt u gId) + case r of + Just (u, gInfo) | hasPublicGroup gInfo -> + void $ renderGroupPreview cfg cc u gInfo + _ -> do + fName <- atomically $ do + pg <- M.lookup gId <$> readTVar publishableGroupIds + modifyTVar' publishableGroupIds (M.delete gId) + pure $ pgFileName <$> pg + forM_ fName $ \f -> + removeFile (webJsonDir f) `catch` \(_ :: SomeException) -> pure () + logInfo $ "web preview: group " <> T.pack (show gId) <> " no longer publishable" + + findUser f = go users + where + go [] = pure Nothing + go (u : us) = f u >>= \case + Right a -> pure (Just a) + Left _ -> go us + +renderGroupPreview :: WebPreviewConfig -> ChatController -> User -> GroupInfo -> IO (Maybe (Text, CorsOrigin)) +renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gInfo@GroupInfo {groupProfile = gp@GroupProfile {shortDescr = sd, description = wd, publicGroup}, groupSummary = GroupSummary {publicMemberCount}} = + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let fName = publicGroupIdFileName publicGroupId <> ".json" + (items, owners) <- withTransaction (chatStore cc) $ \db -> do + is <- getGroupWebPreviewItems db user gInfo webPreviewItemCount + os <- getGroupOwners db cxt user gInfo + pure (is, os) + ts <- getCurrentTime + let rendered = mapMaybe toRenderedItem $ rights items + msgs = map fst rendered + senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + preview = WebChannelPreview + { channel = gp, + shortDescription = toFormattedText =<< sd, + welcomeMessage = toFormattedText =<< wd, + members = senders, + subscribers = publicMemberCount, + messages = msgs, + updatedAt = ts + } + let destPath = webJsonDir fName + tmpPath = destPath <> ".tmp" + LB.writeFile tmpPath (J.encode preview) + renameFile tmpPath destPath + pure $ corsEntry publicGroupId <$> publicGroupAccess + Nothing -> pure Nothing + where + cxt = mkStoreCxt (config cc) + +channelContentChanged :: ChatController -> Int64 -> STM () +channelContentChanged cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + when (M.member gId ids) $ do + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + void $ tryPutTMVar wakeSignal () + +channelProfileUpdated :: ChatController -> Int64 -> GroupProfile -> STM () +channelProfileUpdated cc gId GroupProfile {publicGroup} = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, priorityRender, filesToRemove, corsNeeded, routinePending, wakeSignal} -> + case publicGroup of + Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do + let pg = PublishableGroup + { pgFileName = publicGroupIdFileName publicGroupId <> ".json", + pgCorsEntry = corsEntry publicGroupId <$> publicGroupAccess + } + modifyTVar' publishableGroupIds (M.insert gId pg) + writeTQueue priorityRender gId + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + Nothing -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +channelRemoved :: ChatController -> Int64 -> STM () +channelRemoved cc gId = + forM_ (webPreviewState cc) $ \WebPreviewState {publishableGroupIds, filesToRemove, corsNeeded, routinePending, wakeSignal} -> do + ids <- readTVar publishableGroupIds + forM_ (pgFileName <$> M.lookup gId ids) $ writeTQueue filesToRemove + modifyTVar' publishableGroupIds (M.delete gId) + modifyTVar' routinePending (S.delete gId) + writeTVar corsNeeded True + void $ tryPutTMVar wakeSignal () + +toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) + | isJust itemTimed = Nothing + | otherwise = case ciMsgContent content of + Just mc | not (isReport mc) -> + let (sender, senderProfile) = case chatDir of + CIGroupRcv m@GroupMember {memberId} -> (Just memberId, [memberToProfile m]) + _ -> (Nothing, []) + quotedProfile = case quotedItem of + Just CIQuote {chatDir = CIQGroupRcv (Just m)} -> [memberToProfile m] + _ -> [] + in Just + ( WebMessage + { sender, + ts = itemTs, + content = mc, + formattedText, + file = webFileInfo <$> file, + quote = quotedItem >>= ciQuoteToQuotedMsg, + reactions, + forward = if isJust itemForwarded then Just True else Nothing, + edited = itemEdited + }, + senderProfile <> quotedProfile + ) + _ -> Nothing + +ciQuoteToQuotedMsg :: CIQuote c -> Maybe QuotedMsg +ciQuoteToQuotedMsg CIQuote {chatDir = qDir, sharedMsgId, sentAt, content = qContent} = + Just QuotedMsg + { msgRef = MsgRef + { msgId = sharedMsgId, + sentAt, + sent = case qDir of + CIQDirectSnd -> True + CIQGroupSnd -> True + _ -> False, + memberId = case qDir of + CIQGroupRcv (Just GroupMember {memberId}) -> Just memberId + _ -> Nothing + }, + content = qContent + } + +webFileInfo :: CIFile d -> WebFileInfo +webFileInfo CIFile {fileName, fileSize} = WebFileInfo {fileName, fileSize} + +collectSenders :: [WebMemberProfile] -> [WebMemberProfile] +collectSenders = M.elems . M.fromList . map (\p@WebMemberProfile {memberId} -> (memberId, p)) + +memberToProfile :: GroupMember -> WebMemberProfile +memberToProfile GroupMember {memberId, memberProfile = LocalProfile {displayName, image}} = + WebMemberProfile {memberId, displayName, image} + +toPublishableGroup :: B64UrlByteString -> Maybe PublicGroupAccess -> PublishableGroup +toPublishableGroup pgId access = + PublishableGroup + { pgFileName = publicGroupIdFileName pgId <> ".json", + pgCorsEntry = corsEntry pgId <$> access + } + +corsEntry :: B64UrlByteString -> PublicGroupAccess -> (Text, CorsOrigin) +corsEntry publicGroupId PublicGroupAccess {groupWebPage, allowEmbedding} = + let fName = T.pack $ publicGroupIdFileName publicGroupId <> ".json" + origin + | allowEmbedding = CorsAny + | otherwise = CorsOrigins $ mapMaybe extractOrigin $ maybeToList groupWebPage + in (fName, origin) + +extractOrigin :: Text -> Maybe Text +extractOrigin url = + case U.parseURI U.laxURIParserOptions (encodeUtf8 url) of + Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority = Just _} + | sch == "https" || sch == "http" -> + let originUri = uri {U.uriPath = "", U.uriQuery = U.Query [], U.uriFragment = Nothing} + origin = safeDecodeUtf8 $ U.serializeURIRef' originUri + in if T.all safeOriginChar origin then Just origin else Nothing + _ -> Nothing + where + -- percent-encoded bytes in the host (e.g. %22, %0a) are decoded by serializeURIRef', + -- so reject any origin with characters that could break out of the Caddy CORS config or header + safeOriginChar c = + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c `elem` (".-:/[]" :: [Char]) + +channelPath :: Text +channelPath = "/channel/" + +writeCorsConfig :: [(Text, CorsOrigin)] -> FilePath -> IO () +writeCorsConfig entries path = + TIO.writeFile path $ T.unlines $ + ["map {path} {cors_origin} {"] + <> map corsLine entries + <> [ " default \"\"", + "}", + "header " <> channelPath <> "*.json Access-Control-Allow-Origin {cors_origin}", + "header " <> channelPath <> "*.json Access-Control-Allow-Methods \"GET, OPTIONS\"" + ] + where + corsLine (fName, origin) = case origin of + CorsAny -> " " <> channelPath <> fName <> " \"*\"" + CorsOrigins origins -> case origins of + [] -> " # " <> fName <> " (no origin configured)" + (o : _) -> " " <> channelPath <> fName <> " \"" <> o <> "\"" + +removeStaleFiles :: FilePath -> S.Set FilePath -> IO () +removeStaleFiles dir activeFiles = do + let -- matches ".json" and leftover ".json.tmp" from an interrupted write + isPreviewFile f = + let f' = if takeExtension f == ".tmp" then dropExtension f else f + base = dropExtension f' + in takeExtension f' == ".json" && not (null base) && all isBase64Url base + isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' + allFiles <- S.filter isPreviewFile . S.fromList <$> listDirectory dir + mapM_ (\f -> removeFile (dir f)) $ S.difference allFiles activeFiles + +toFormattedText :: Text -> Maybe MarkdownList +toFormattedText t = case parseMaybeMarkdownList t of + Just fts | any hasFormat fts -> Just fts + _ -> Nothing + where + hasFormat (FormattedText fmt _) = isJust fmt + +publicGroupIdFileName :: B64UrlByteString -> String +publicGroupIdFileName = B.unpack . strEncode + +hasPublicGroup :: GroupInfo -> Bool +hasPublicGroup GroupInfo {groupProfile = GroupProfile {publicGroup}} = isJust publicGroup + diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index f92411839f..cd6d549581 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -27,8 +27,10 @@ import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (memberSupportVoiceVersion) import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Version import System.FilePath (()) import Test.Hspec hiding (it) @@ -1492,7 +1494,7 @@ testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> - withNewTestChatCfg ps testCfgVPrev "cath" cathProfile $ \cath -> do + withNewTestChatCfg ps testCfg {chatVRange = (chatVRange testCfg) {maxVersion = prevVersion memberSupportVoiceVersion}} "cath" cathProfile $ \cath -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" bob #> "@'SimpleX Directory' /role 1" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ede3c1f2a2..442834b244 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -24,11 +24,12 @@ import Control.Monad.Reader import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (isNothing) +import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), WebPreviewConfig (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options @@ -153,6 +154,7 @@ testCoreOpts = tbqSize = 16, deviceName = Nothing, chatRelay = False, + webPreviewConfig = Nothing, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Nothing, @@ -162,6 +164,9 @@ testCoreOpts = relayTestOpts :: ChatOpts relayTestOpts = testOpts {coreOptions = testCoreOpts {chatRelay = True}} +relayWebTestOpts :: Text -> FilePath -> Maybe FilePath -> ChatOpts +relayWebTestOpts webDomain webDir webCorsFile = testOpts {coreOptions = testCoreOpts {chatRelay = True, webPreviewConfig = Just WebPreviewConfig {webDomain, webJsonDir = webDir, webCorsFile, webUpdateInterval = 300, webPreviewItemCount = 50}}} + #if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts getTestOpts maintenance dbKey = testOpts {coreOptions = testCoreOpts {maintenance, dbOptions = (dbOptions testCoreOpts) {dbKey}}} diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 4b09347dcf..57095fb28f 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -1,5 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module ChatTests.ChatRelays where @@ -16,8 +18,13 @@ import qualified Data.Text as T import ProtocolTests (testGroupProfile) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) import Simplex.Chat.Types (GroupProfile (..)) +import Simplex.Chat.Controller (CorsOrigin (..)) +import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) +import qualified Data.Set as S +import System.Directory (createDirectoryIfMissing, doesFileExist, listDirectory) +import System.FilePath (takeExtension, ()) import Test.Hspec hiding (it) chatRelayTests :: SpecWith TestParams @@ -28,6 +35,19 @@ chatRelayTests = do it "re-add soft-deleted relay by same name" testReAddRelaySameName it "test chat relay" testChatRelayTest it "relay profile updated in address" testRelayProfileUpdateInAddress + describe "relay capabilities" $ do + it "relay sends webDomain in capabilities" testRelayWebCapabilities + describe "web preview" $ do + it "render messages and members" testWebPreviewRender + it "incremental render adds new messages" testWebPreviewIncremental + it "edited and deleted messages" testWebPreviewEditedDeleted + it "reactions in rendered messages" testWebPreviewReactions + it "non-public group produces no file" testWebPreviewNonPublic + it "multiple channels produce multiple files" testWebPreviewMultipleChannels + it "channel deletion removes preview file" testWebPreviewChannelDeleted + it "removeStaleFiles preserves non-base64url files" testWebPreviewStaleCleanup + it "generate CORS config" testWebPreviewCors + it "extractOrigin strips path from URL" testExtractOrigin describe "share channel card" $ do it "share channel card in direct chat" testShareChannelDirect it "share channel card in group" testShareChannelGroup @@ -325,6 +345,238 @@ testShareChannelChannel ps = getTermLine2 :: TestCC -> IO (String, String) getTermLine2 c = (,) <$> getTermLine c <*> getTermLine c +testRelayWebCapabilities :: HasCallStack => TestParams -> IO () +testRelayWebCapabilities ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" (tmpPath ps "web_cap") Nothing) "bob" bobProfile $ \relay -> do + rName <- userName relay + relay ##> "/ad" + (relaySLink, _cLink) <- getContactLinks relay True + alice ##> ("/relays name=" <> rName <> " " <> relaySLink) + alice <## "ok" + alice ##> "/public group relays=1 #news" + alice <## "group #news is created" + alice <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + alice <## "#news: group link relays updated, current relays:" + alice <### [EndsWith ": active, web: relay.example.com"] + alice <## "group link:" + _ <- getTermLine alice + pure (), + relay <## "#news: you joined the group as relay" + ] + +-- Helper: set up relay with web config + channel +withWebChannel :: TestParams -> String -> (TestCC -> TestCC -> FilePath -> IO ()) -> IO () +withWebChannel ps gName test = do + let webDir = tmpPath ps "web_" <> gName + corsFile = tmpPath ps "cors_" <> gName <> ".conf" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir (Just corsFile)) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb gName alice relay + test alice relay webDir + +createChannelWithRelayWeb :: HasCallStack => String -> TestCC -> TestCC -> IO () +createChannelWithRelayWeb gName owner relay = do + owner ##> ("/public group relays=1 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner <### [EndsWith ": active, web: relay.example.com"] + owner <## "group link:" + _ <- getTermLine owner + pure (), + relay <## ("#" <> gName <> ": you joined the group as relay") + ] + +-- Poll for a JSON preview file written by the worker that satisfies predicate, with timeout +waitPreviewWith :: HasCallStack => FilePath -> (WebChannelPreview -> Bool) -> IO WebChannelPreview +waitPreviewWith webDir check = go 50 + where + go :: Int -> IO WebChannelPreview + go 0 = error "waitPreview: timed out waiting for matching JSON file" + go n = do + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + case files of + [f] -> do + jsonBytes <- LB.readFile (webDir f) + case J.eitherDecode jsonBytes of + Right p | check p -> pure p + _ -> threadDelay 100000 >> go (n - 1) + _ -> threadDelay 100000 >> go (n - 1) + +waitPreview :: HasCallStack => FilePath -> IO WebChannelPreview +waitPreview webDir = waitPreviewWith webDir (const True) + +testWebPreviewRender :: HasCallStack => TestParams -> IO () +testWebPreviewRender ps = + withWebChannel ps "news" $ \alice relay webDir -> do + alice #> "#news hello from the channel" + relay <# "#news> hello from the channel" + alice #> "#news second message" + relay <# "#news> second message" + wPreview <- waitPreviewWith webDir (\p -> length (messages p) >= 2) + let GroupProfile {displayName = chName} = channel wPreview + chName `shouldBe` "news" + length (messages wPreview) `shouldBe` 2 + content (messages wPreview !! 0) `shouldBe` MCText "hello from the channel" + content (messages wPreview !! 1) `shouldBe` MCText "second message" + length (members wPreview) `shouldSatisfy` (>= 1) + all (\m -> ts m > read "2020-01-01 00:00:00 UTC") (messages wPreview) `shouldBe` True + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + +testWebPreviewIncremental :: HasCallStack => TestParams -> IO () +testWebPreviewIncremental ps = + withWebChannel ps "inc" $ \alice relay webDir -> do + alice #> "#inc first" + relay <# "#inc> first" + p1 <- waitPreviewWith webDir (\p -> length (messages p) >= 1) + length (messages p1) `shouldBe` 1 + content (messages p1 !! 0) `shouldBe` MCText "first" + alice #> "#inc second" + relay <# "#inc> second" + alice #> "#inc third" + relay <# "#inc> third" + p2 <- waitPreviewWith webDir (\p -> length (messages p) >= 3) + length (messages p2) `shouldBe` 3 + content (messages p2 !! 0) `shouldBe` MCText "first" + content (messages p2 !! 1) `shouldBe` MCText "second" + content (messages p2 !! 2) `shouldBe` MCText "third" + +testWebPreviewEditedDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewEditedDeleted ps = + withWebChannel ps "ed" $ \alice relay webDir -> do + alice #> "#ed msg one" + relay <# "#ed> msg one" + alice #> "#ed msg two" + relay <# "#ed> msg two" + msgId2 <- lastItemId alice + alice #> "#ed msg three" + relay <# "#ed> msg three" + msgId3 <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId2 <> " text msg two edited") + alice <# "#ed [edited] msg two edited" + relay <# "#ed> [edited] msg two edited" + alice #$> ("/_delete item #1 " <> msgId3 <> " broadcast", id, "message marked deleted") + relay <# "#ed> [marked deleted] msg three" + p <- waitPreviewWith webDir (\p -> length (messages p) == 2 && any edited (messages p)) + length (messages p) `shouldBe` 2 + content (messages p !! 0) `shouldBe` MCText "msg one" + content (messages p !! 1) `shouldBe` MCText "msg two edited" + edited (messages p !! 0) `shouldBe` False + edited (messages p !! 1) `shouldBe` True + +testWebPreviewReactions :: HasCallStack => TestParams -> IO () +testWebPreviewReactions ps = + withWebChannel ps "react" $ \alice relay webDir -> do + alice #> "#react hello" + relay <# "#react> hello" + alice ##> "+1 #react hello" + alice <## "added 👍" + relay <# "#react alice> > hello" + relay <## " + 👍" + p <- waitPreviewWith webDir (\p -> not (null (messages p)) && not (null (reactions (head (messages p))))) + length (messages p) `shouldBe` 1 + length (reactions (messages p !! 0)) `shouldSatisfy` (>= 1) + +testWebPreviewNonPublic :: HasCallStack => TestParams -> IO () +testWebPreviewNonPublic ps = do + let webDir = tmpPath ps "web_nonpub" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + alice ##> "/g private" + alice <## "group #private is created" + alice <## "to add members use /a private or /create link #private" + alice #> "#private hello" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 0 + +testWebPreviewMultipleChannels :: HasCallStack => TestParams -> IO () +testWebPreviewMultipleChannels ps = do + let webDir = tmpPath ps "web_multi" + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps (relayWebTestOpts "relay.example.com" webDir Nothing) "bob" bobProfile $ \relay -> do + _ <- setupRelay alice relay + createChannelWithRelayWeb "ch1" alice relay + createChannelWithRelayWeb "ch2" alice relay + alice #> "#ch1 msg in ch1" + relay <# "#ch1> msg in ch1" + alice #> "#ch2 msg in ch2" + relay <# "#ch2> msg in ch2" + threadDelay 2000000 + files <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length files `shouldBe` 2 + +testWebPreviewChannelDeleted :: HasCallStack => TestParams -> IO () +testWebPreviewChannelDeleted ps = + withWebChannel ps "del" $ \alice relay webDir -> do + alice #> "#del hello" + relay <# "#del> hello" + _ <- waitPreviewWith webDir (\p -> not (null (messages p))) + jsonFiles <- filter (\f -> takeExtension f == ".json") <$> listDirectory webDir + length jsonFiles `shouldBe` 1 + let previewFile = webDir head jsonFiles + alice ##> "/d #del" + alice <## "#del: you deleted the group (signed)" + relay <## "#del: alice deleted the group (signed)" + relay <## "use /d #del to delete the local copy of the group" + waitFileDeleted previewFile 50 + +testWebPreviewStaleCleanup :: HasCallStack => TestParams -> IO () +testWebPreviewStaleCleanup ps = do + let webDir = tmpPath ps "web_stale_unit" + activeFile = "abc123.json" + staleFile = "AAAA_stale.json" + safeFile = "my.config.json" + createDirectoryIfMissing True webDir + writeFile (webDir activeFile) "{}" + writeFile (webDir staleFile) "{}" + writeFile (webDir safeFile) "{}" + removeStaleFiles webDir (S.singleton activeFile) + doesFileExist (webDir staleFile) `shouldReturn` False + doesFileExist (webDir safeFile) `shouldReturn` True + doesFileExist (webDir activeFile) `shouldReturn` True + +waitFileDeleted :: HasCallStack => FilePath -> Int -> IO () +waitFileDeleted _ 0 = error "waitFileDeleted: timed out" +waitFileDeleted path n = + doesFileExist path >>= \case + False -> pure () + True -> threadDelay 100000 >> waitFileDeleted path (n - 1) + +testWebPreviewCors :: HasCallStack => TestParams -> IO () +testWebPreviewCors ps = do + let corsFile = tmpPath ps "simplex-cors.conf" + entries = + [ ("abc123.json", CorsAny), + ("def456.json", CorsOrigins ["https://owner-site.com"]), + ("ghi789.json", CorsOrigins []) + ] + writeCorsConfig entries corsFile + corsContent <- readFile corsFile + corsContent `shouldContain` "/channel/abc123.json \"*\"" + corsContent `shouldContain` "/channel/def456.json \"https://owner-site.com\"" + corsContent `shouldContain` "# ghi789.json (no origin configured)" + corsContent `shouldContain` "Access-Control-Allow-Origin" + corsContent `shouldContain` "Access-Control-Allow-Methods" + +testExtractOrigin :: HasCallStack => TestParams -> IO () +testExtractOrigin _ps = do + extractOrigin "https://owner.example.com/channel.html" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com/path/to/page?q=1#frag" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com:8443/page" `shouldBe` Just "https://owner.example.com:8443" + extractOrigin "https://owner.example.com" `shouldBe` Just "https://owner.example.com" + extractOrigin "http://localhost:3000/preview" `shouldBe` Just "http://localhost:3000" + extractOrigin "ftp://example.com/file" `shouldBe` Nothing + extractOrigin "not-a-url" `shouldBe` Nothing + -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 10f8808015..1482a9de10 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,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 (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-17\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-18\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) 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\"}}}}" @@ -244,13 +244,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, memberKey = Nothing} Nothing 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-17\",\"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-18\",\"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, memberKey = Nothing} Nothing 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, memberKey = Nothing} Nothing 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-17\",\"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-18\",\"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, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"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\"}}}}}}" @@ -265,7 +265,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-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%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-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%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, memberKey = Nothing} 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-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"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-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"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, memberKey = Nothing} 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\"}}}}}" diff --git a/website/.eleventy.js b/website/.eleventy.js index f0310c5665..0567dd45d8 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -310,7 +310,7 @@ module.exports = function (ty) { ty.addPassthroughCopy("src/img") ty.addPassthroughCopy("src/video") ty.addPassthroughCopy("src/css") - ty.addPassthroughCopy("src/js") + ty.addPassthroughCopy("src/js/**/*.js") ty.addPassthroughCopy("src/lottie_file") ty.addPassthroughCopy("src/contact/*.js") ty.addPassthroughCopy("src/call") diff --git a/website/channel_sample.html b/website/channel_sample.html new file mode 100644 index 0000000000..169db55599 --- /dev/null +++ b/website/channel_sample.html @@ -0,0 +1,28 @@ + + + + + + SimpleX Channel Preview + + + + +
+ + + diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc new file mode 100644 index 0000000000..be572fd8de --- /dev/null +++ b/website/src/js/channel-preview.jsc @@ -0,0 +1,1548 @@ +#include "simplex-lib.jsc" + +(function() { + +#include "qrcode.js" + +const STYLE = ` +.simplex-preview-container { + --sp-bg: var(--sp-light-bg, #fff); + --sp-text: #000; + --sp-text-secondary: #8b8786; + --sp-text-muted: #333; + --sp-text-small: #888; + --sp-bubble: #f5f5f6; + --sp-quote: #ececee; + --sp-border: #e5e5e5; + --sp-link: #0088ff; + --sp-link-hover: #0077e0; + --sp-btn: #007AE5; + --sp-btn-hover: #006BC9; + --sp-color-blue: #0053d0; + --sp-color-black: #000; + --sp-color-white: #000; + --sp-qr-fg: #062D56; + --sp-qr-bg: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.4; + color: var(--sp-text); + background: var(--sp-bg); + width: 100%; + height: 100%; + padding: 0; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + display: flex; + justify-content: center; +} + +.simplex-preview-container.simplex-scheme-dark, +.dark .simplex-preview-container.simplex-scheme-site { + --sp-bg: var(--sp-dark-bg, #000832); + --sp-text: #FFFBFA; + --sp-text-secondary: #B3AFAE; + --sp-text-muted: #B3AFAE; + --sp-text-small: #aaa; + --sp-bubble: #071C46; + --sp-quote: #1B325C; + --sp-border: #3A3A3C; + --sp-link: #70F0F9; + --sp-link-hover: #66D9E2; + --sp-btn: #7EF1F9; + --sp-btn-hover: #75DCE4; + --sp-btn-text: #000; + --sp-color-blue: #70F0F9; + --sp-color-black: #fff; + --sp-color-white: #fff; + --sp-qr-fg: #FFFBFA; + --sp-qr-bg: transparent; +} + +.simplex-preview-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--sp-bg); + border-bottom: 1px solid var(--sp-border); + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.simplex-preview-header-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.simplex-preview-header-info { + flex: 1; + min-width: 0; +} + +.simplex-preview-header-name { + font-size: 17px; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-header-description { + font-size: 13px; + color: var(--sp-text-secondary); + margin: 2px 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-join-btn { + flex-shrink: 0; + background: var(--sp-btn); + color: var(--sp-btn-text, #fff); + border: none; + border-radius: 34px; + padding: 6px 10px 6px 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: inherit; +} + +.simplex-preview-join-btn svg { + width: 15.4px; + height: 15.4px; + flex-shrink: 0; + margin-left: 2px; +} + +.simplex-preview-container .simplex-logo-light-bg { + display: none; +} + +.simplex-preview-container.simplex-scheme-dark .simplex-logo-dark-bg, +.dark .simplex-preview-container.simplex-scheme-site .simplex-logo-dark-bg { + display: none; +} + +.simplex-preview-container.simplex-scheme-dark .simplex-logo-light-bg, +.dark .simplex-preview-container.simplex-scheme-site .simplex-logo-light-bg { + display: inline; +} + +.simplex-preview-join-btn:hover { + background: var(--sp-btn-hover); +} + +.simplex-preview-messages { + padding: 8px 16px 32px; +} + +.simplex-preview-date-separator { + text-align: center; + padding: 8px 0; + font-size: 12px; + color: var(--sp-text-secondary); + font-weight: 500; +} + +.simplex-preview-msg-group { + padding: 0 8px; +} + +.simplex-preview-msg-name { + font-size: 13.5px; + color: var(--sp-text-secondary); + padding: 0 0 2px 0; + margin-left: 39px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-msg-name-role { + font-weight: 500; + margin-left: 8px; +} + +.simplex-preview-msg-row { + display: flex; + align-items: flex-start; + margin-bottom: 2px; +} + +.simplex-preview-msg-row.has-gap { + margin-bottom: 6px; +} + +.simplex-preview-msg-avatar { + width: 30px; + height: 30px; + border-radius: 7px; + object-fit: cover; + flex-shrink: 0; + margin-right: 9px; +} + +.simplex-preview-msg-avatar-placeholder { + width: 30px; + flex-shrink: 0; + margin-right: 9px; +} + +.simplex-preview-bubble { + position: relative; + background: var(--sp-bubble); + border-radius: 18px; + min-width: 80px; + overflow: visible; +} + +.simplex-preview-bubble-inner { + border-radius: 18px; + overflow: hidden; +} + +.simplex-preview-bubble.has-tail { + border-bottom-left-radius: 0; +} + +.simplex-preview-bubble.has-tail .simplex-preview-bubble-inner { + border-bottom-left-radius: 0; +} + +.simplex-preview-bubble-tail { + position: absolute; + bottom: 0; + left: -9px; + width: 9px; + height: 16px; + color: var(--sp-bubble); +} + +.simplex-preview-bubble.media-only { + background: transparent; +} + +.simplex-preview-meta-overlay { + position: absolute; + bottom: 6px; + right: 12px; + font-size: 12px; + color: #fff; + text-shadow: 0 0 4px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.9); + white-space: nowrap; +} + +.simplex-preview-meta-overlay .simplex-preview-meta-edited { + font-style: italic; +} + +.simplex-preview-forwarded-header { + background: var(--sp-quote); + padding: 6px 12px 6px 8px; + font-size: 12px; + font-style: italic; + color: var(--sp-text-secondary); + display: flex; + align-items: center; + gap: 4px; +} + +.simplex-preview-quote { + background: var(--sp-quote); + display: flex; + width: 100%; +} + +.simplex-preview-quote-content { + flex: 1; + padding: 6px 12px; + min-width: 0; +} + +.simplex-preview-quote-sender { + font-size: 13.5px; + color: var(--sp-text-secondary); + margin-bottom: 2px; +} + +.simplex-preview-quote-text { + font-size: 15px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.simplex-preview-quote-thumb { + width: 68px; + height: 68px; + object-fit: cover; + flex-shrink: 0; +} + +.simplex-preview-quote-file-icon { + padding: 6px 4px 0 0; + flex-shrink: 0; + color: var(--sp-text-secondary); +} + +.simplex-preview-text { + padding: 7px 12px; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.simplex-preview-text a { + color: var(--sp-link); + text-decoration: none; +} + +.simplex-preview-text a:hover { + text-decoration: underline; +} + +.simplex-preview-image { + display: block; + max-width: 100%; +} + +.simplex-preview-image.landscape { + width: 400px; +} + +.simplex-preview-image.portrait { + width: 300px; +} + +.simplex-preview-image-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 80px; + background: var(--sp-quote); + border-radius: 12px; + color: var(--sp-text-secondary); +} + +.simplex-preview-image-placeholder svg { + width: 32px; + height: 32px; +} + +.simplex-preview-link-card { + display: block; + max-width: 400px; +} + +.simplex-preview-link-card-image { + display: block; + width: 100%; +} + +.simplex-preview-link-card-body { + padding: 6px 12px; +} + +.simplex-preview-link-card-title { + font-size: 15px; + line-height: 22px; + margin-bottom: 4px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.simplex-preview-link-card-description { + font-size: 14px; + line-height: 20px; + color: var(--sp-text-muted); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 12; + -webkit-box-orient: vertical; +} + +.simplex-preview-link-card-uri { + font-size: 12px; + color: var(--sp-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.simplex-preview-file-indicator { + padding: 7px 12px; + display: flex; + align-items: center; + gap: 8px; + color: var(--sp-text-secondary); +} + +.simplex-preview-file-icon { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.simplex-preview-file-name { + font-size: 14px; + color: var(--sp-text); +} + +.simplex-preview-file-size { + font-size: 12px; + color: var(--sp-text-secondary); +} + +.simplex-preview-voice { + padding: 7px 12px; + display: flex; + align-items: center; + gap: 8px; + color: var(--sp-text-secondary); + font-size: 14px; +} + +.simplex-preview-meta { + float: right; + font-size: 12px; + color: var(--sp-text-secondary); + padding: 0 2px 0 12px; + margin-top: 4px; + white-space: nowrap; +} + +.simplex-preview-meta-edited { + font-style: italic; +} + +.simplex-preview-reactions { + display: flex; + flex-wrap: wrap; + padding: 2px 5px 2px; +} + +.simplex-preview-reaction { + font-size: 12px; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; + border-radius: 8px; + padding: 2px 5px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.simplex-preview-reaction-count { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--sp-text-secondary); + font-size: 11.5px; +} + +.simplex-preview-empty { + text-align: center; + padding: 48px 16px; + color: var(--sp-text-secondary); +} + +.simplex-preview-text .secret { + background: var(--sp-text-secondary); + color: transparent; + border-radius: 4px; + cursor: pointer; + user-select: none; + transition: all 0.2s; +} + +.simplex-preview-text .secret.visible { + background: transparent; + color: inherit; +} + +.simplex-preview-text .small-text { + font-size: 13px; + color: var(--sp-text-small); +} + +.simplex-preview-text .red { color: #DD0000; } +.simplex-preview-text .green { color: #20BD3D; } +.simplex-preview-text .blue { color: var(--sp-color-blue); } +.simplex-preview-text .yellow { color: #DEBD00; } +.simplex-preview-text .cyan { color: #0AC4D1; } +.simplex-preview-text .magenta { color: magenta; } +.simplex-preview-text .black { color: var(--sp-color-black); } +.simplex-preview-text .white { color: var(--sp-color-white); } + +.simplex-preview-main { + flex: 1; + min-width: 0; + max-width: 640px; + overflow-y: auto; + overscroll-behavior: contain; + position: relative; +} + +.simplex-preview-info { + overflow-y: auto; + overscroll-behavior: contain; + background: var(--sp-bg); +} + +.simplex-preview-info-close { + display: none; +} + +.simplex-preview-info-avatar { + width: 192px; + height: 192px; + border-radius: 42px; + object-fit: cover; + display: block; + margin: 12px auto; +} + +.simplex-preview-info-name { + font-size: 34px; + font-weight: 700; + text-align: center; + margin: 0; +} + +.simplex-preview-info-descr { + font-size: 14px; + color: var(--sp-text-secondary); + text-align: center; + margin: 8px 0; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.simplex-preview-info-descr a { + color: var(--sp-link); + text-decoration: none; +} + +.simplex-preview-info-descr a:hover { + text-decoration: underline; +} + +.simplex-preview-info-subscribers { + font-size: 14px; + color: var(--sp-text-secondary); + text-align: center; + margin: 0 0 16px; +} + +.simplex-preview-info .simplex-preview-join-btn { + display: block; + text-align: center; + margin-top: 20px; + width: 100%; +} + +.simplex-preview-conversion { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.simplex-preview-divider { + width: 100%; + height: 1px; + background: var(--sp-border); + margin: 40px 0; +} + +.simplex-preview-conversion-title { + font-size: 18px; + font-weight: 600; + text-align: center; + margin: 0 0 16px; +} + +.simplex-preview-qr-toggle { + font-size: 14px; + color: var(--sp-link); + cursor: pointer; + text-decoration: none; +} + +.simplex-preview-qr-toggle:hover { + text-decoration: underline; +} + +.simplex-preview-qr-popup { + flex-direction: column; + align-items: center; + gap: 8px; +} + +.simplex-preview-qr-popup canvas { + border-radius: 8px; +} + +.simplex-preview-qr-caption { + font-size: 14px; + text-align: center; + margin: 0; +} + +.simplex-preview-badges { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin: 0 0 6px; +} + +.simplex-preview-badges a { + display: block; +} + +.simplex-preview-badges a img { + height: 40px; + width: auto; + display: block; +} + +.simplex-preview-copy-action { + font-size: 14px; + margin: 0; +} + +.simplex-preview-copy-action a { + color: var(--sp-link); + text-decoration: none; + cursor: pointer; +} + +.simplex-preview-copy-action a:hover { + text-decoration: underline; +} + +.simplex-preview-step-title { + font-size: 14px; + text-align: center; + margin: 0 0 -8px; +} + +.simplex-preview-open-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--sp-btn); + color: var(--sp-btn-text, #fff); + border: none; + border-radius: 34px; + padding: 16px 12px 16px 18px; + height: 44px; + font-size: 16px; + line-height: 19px; + letter-spacing: 0.02em; + cursor: pointer; + text-decoration: none; + font-family: inherit; + margin-top: 3px; +} + +.simplex-preview-open-btn svg { + width: 22px; + height: 22px; + flex-shrink: 0; + margin-left: 6px; +} + + +.simplex-preview-open-btn:hover { + background: var(--sp-btn-hover); +} + +@media (min-width: 1000px) { + .simplex-preview-info { + width: 320px; + flex-shrink: 0; + border-left: 1px solid var(--sp-border); + padding: 24px; + } + .simplex-preview-header .simplex-preview-join-btn { + display: none; + } +} + +@media (max-width: 999px) { + .simplex-preview-container { + font-size: 17px; + } + .simplex-preview-main { + max-width: none; + } + .simplex-preview-info { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + padding: 16px; + } + .simplex-preview-info.open { + display: block; + } + .simplex-preview-info-close { + display: block; + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 24px; + color: var(--sp-text-secondary); + cursor: pointer; + padding: 4px 8px; + line-height: 1; + } + .simplex-preview-info-content { + padding-top: 32px; + } + .simplex-preview-header { + cursor: pointer; + } +} +`; + +const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent(''); + +const IMAGE_PLACEHOLDER_SVG = ``; + +function isDataImage(src) { + return typeof src === 'string' && src.startsWith('data:image/'); +} + +function tailSvg() { + return ''; +} + +var _logoId = 0; +var _svgParser = new DOMParser(); + +function appendSimplexLogo(el) { + var n = _logoId++; + var darkSvg = '' + + '' + + '' + + ''; + var lightSvg = '' + + '' + + '' + + ''; + el.appendChild(document.importNode(_svgParser.parseFromString(darkSvg, 'image/svg+xml').documentElement, true)); + el.appendChild(document.importNode(_svgParser.parseFromString(lightSvg, 'image/svg+xml').documentElement, true)); +} + +const FILE_ICON_SVG = ``; + +const VOICE_ICON_SVG = ``; + +const FORWARD_ICON_SVG = ``; + +const COPY_ICON_SVG = ``; + +function initChannelPreview(container) { + const relayDomains = (container.dataset.relayDomains || '').split(',').map(u => u.trim()).filter(Boolean); + const relayScheme = container.dataset.relayScheme || 'https'; + const channelId = container.dataset.channelId || ''; + const channelLink = container.dataset.channelLink || ''; + const showAppBadges = container.dataset.appDownloadButtons !== 'off'; + const colorScheme = container.dataset.colorScheme || 'light'; + + if (!relayDomains.length || !channelId) { + container.innerHTML = '

Missing configuration: data-relay-domains and data-channel-id required.

'; + return; + } + + injectStyles(); + container.classList.add('simplex-preview-container', 'simplex-scheme-' + colorScheme); + if (container.dataset.lightBackground) { + container.style.setProperty('--sp-light-bg', container.dataset.lightBackground); + } + if (container.dataset.darkBackground) { + container.style.setProperty('--sp-dark-bg', container.dataset.darkBackground); + } + container.innerHTML = '

Loading channel...

'; + + fetchPreview(relayScheme, relayDomains, channelLink, channelId).then(data => { + if (data === 'link_mismatch') { + container.innerHTML = '

All relays returned a different channel link from specified in the page.

'; + return; + } + if (!data) { + container.innerHTML = '

Failed to load channel preview.

'; + return; + } + render(container, data, channelLink, showAppBadges); + }); +} + +let stylesInjected = false; +function injectStyles() { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement('style'); + style.textContent = STYLE; + document.head.appendChild(style); +} + +async function fetchPreview(relayScheme, relayDomains, channelLink, channelId) { + let linkMismatch = false; + for (const domain of relayDomains) { + try { + const url = `${relayScheme}://${domain}/channel/${channelId}.json`; + const resp = await fetch(url); + if (!resp.ok) continue; + const data = await resp.json(); + const relayLink = data.channel?.publicGroup?.groupLink; + if (channelLink && relayLink && channelLink !== relayLink) { + linkMismatch = true; + continue; + } + return data; + } catch(e) { + continue; + } + } + return linkMismatch ? 'link_mismatch' : null; +} + +function render(container, data, channelLink, showAppBadges) { + const { channel, members, messages } = data; + const membersMap = {}; + for (const m of members) { + membersMap[m.memberId] = m; + } + + container.innerHTML = ''; + + const main = document.createElement('div'); + main.className = 'simplex-preview-main'; + + const header = renderHeader(channel, channelLink, data.subscribers); + main.appendChild(header); + + const messagesDiv = document.createElement('div'); + messagesDiv.className = 'simplex-preview-messages'; + const welcome = data.welcomeMessage || channel.description; + var allMessages = messages; + if (welcome) { + var welcomeMsg = { + sender: null, + ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), + content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, + formattedText: Array.isArray(welcome) ? welcome : null, + reactions: [] + }; + allMessages = [welcomeMsg].concat(messages); + } + renderMessages(messagesDiv, allMessages, membersMap, channel); + main.appendChild(messagesDiv); + + container.appendChild(main); + + const info = document.createElement('div'); + info.className = 'simplex-preview-info'; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'simplex-preview-info-close'; + closeBtn.innerHTML = '✕'; + info.appendChild(closeBtn); + + const infoContent = document.createElement('div'); + infoContent.className = 'simplex-preview-info-content'; + renderInfoContent(infoContent, data, channelLink, data.subscribers, showAppBadges); + info.appendChild(infoContent); + + container.appendChild(info); + + header.addEventListener('click', (e) => { + if (e.target.closest('.simplex-preview-join-btn')) return; + if (window.innerWidth < 1000) { + info.classList.add('open'); + main.style.overflow = 'hidden'; + } + }); + + closeBtn.addEventListener('click', () => { + info.classList.remove('open'); + main.style.overflow = ''; + }); + + setupSecretToggles(container); + setTimeout(() => { main.scrollTop = main.scrollHeight; }, 0); +} + +function renderHeader(channel, channelLink, subscriberCount) { + const header = document.createElement('div'); + header.className = 'simplex-preview-header'; + + const avatar = document.createElement('img'); + avatar.className = 'simplex-preview-header-avatar'; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; + avatar.alt = channel.displayName; + header.appendChild(avatar); + + const info = document.createElement('div'); + info.className = 'simplex-preview-header-info'; + + const name = document.createElement('h1'); + name.className = 'simplex-preview-header-name'; + name.textContent = channel.displayName; + info.appendChild(name); + + if (subscriberCount > 0) { + const desc = document.createElement('p'); + desc.className = 'simplex-preview-header-description'; + desc.textContent = subscriberCount + ' subscribers'; + info.appendChild(desc); + } + + header.appendChild(info); + + if (channelLink) { + const btn = document.createElement('a'); + btn.className = 'simplex-preview-join-btn'; + btn.textContent = 'Join'; + appendSimplexLogo(btn); + btn.href = channelLink; + header.appendChild(btn); + } + + return header; +} + +function renderInfoContent(container, data, channelLink, subscriberCount, showAppBadges) { + const { channel } = data; + + const avatar = document.createElement('img'); + avatar.className = 'simplex-preview-info-avatar'; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; + avatar.alt = channel.displayName; + container.appendChild(avatar); + + const name = document.createElement('h2'); + name.className = 'simplex-preview-info-name'; + name.textContent = channel.displayName; + container.appendChild(name); + + const shortDescr = data.shortDescription || channel.shortDescr; + if (shortDescr) { + const descrDiv = document.createElement('div'); + descrDiv.className = 'simplex-preview-info-descr'; + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + container.appendChild(descrDiv); + } + + if (subscriberCount > 0) { + const subs = document.createElement('p'); + subs.className = 'simplex-preview-info-subscribers'; + subs.textContent = subscriberCount + ' subscribers'; + container.appendChild(subs); + } + + if (channelLink) { + if (!isMobile.any()) { + const openBtn = document.createElement('a'); + openBtn.className = 'simplex-preview-open-btn'; + openBtn.style.display = 'flex'; + openBtn.style.width = 'fit-content'; + openBtn.style.margin = '32px auto 0'; + openBtn.textContent = 'Join in SimpleX Chat'; + appendSimplexLogo(openBtn); + openBtn.href = channelLink; + container.appendChild(openBtn); + } + + const showJoinSection = !isMobile.any() || showAppBadges; + if (showJoinSection) { + const divider = document.createElement('div'); + divider.className = 'simplex-preview-divider'; + container.appendChild(divider); + + const joinTitle = document.createElement('p'); + joinTitle.className = 'simplex-preview-conversion-title'; + joinTitle.textContent = 'To join this channel'; + container.appendChild(joinTitle); + } + + const conversion = document.createElement('div'); + conversion.className = 'simplex-preview-conversion'; + if (!showJoinSection) { + conversion.style.marginTop = '28px'; + } + if (isMobile.any()) { + renderMobileConversion(conversion, channelLink, showAppBadges); + } else { + renderDesktopConversion(conversion, channelLink, showAppBadges); + } + container.appendChild(conversion); + } +} + +var BADGE_APPLE = 'App Store'; +var BADGE_GOOGLE = 'Google Play'; +var BADGE_FDROID = 'F-Droid'; +var BADGE_APK = 'APK Download'; +var BADGE_TESTFLIGHT = 'TestFlight'; + +function renderAppBadges(container) { + const title = document.createElement('p'); + title.className = 'simplex-preview-step-title'; + title.textContent = 'Install SimpleX Chat app'; + container.appendChild(title); + + const badges = document.createElement('div'); + badges.className = 'simplex-preview-badges'; + if (isMobile.Android()) { + badges.innerHTML = BADGE_GOOGLE + BADGE_FDROID + BADGE_APK; + } else if (isMobile.iOS()) { + badges.innerHTML = BADGE_APPLE + BADGE_TESTFLIGHT; + } else { + badges.innerHTML = BADGE_APPLE + BADGE_GOOGLE; + } + container.appendChild(badges); +} + +function renderDesktopConversion(container, channelLink, showAppBadges) { + if (showAppBadges) { + renderAppBadges(container); + } + + const qrToggle = document.createElement('a'); + qrToggle.className = 'simplex-preview-qr-toggle'; + qrToggle.textContent = 'Show QR code for mobile app'; + qrToggle.href = '#'; + container.appendChild(qrToggle); + + const qrPopup = document.createElement('div'); + qrPopup.className = 'simplex-preview-qr-popup'; + qrPopup.style.display = 'none'; + + const caption = document.createElement('p'); + caption.className = 'simplex-preview-qr-caption'; + caption.textContent = 'Scan from SimpleX Chat app'; + qrPopup.appendChild(caption); + + const canvas = document.createElement('canvas'); + qrPopup.appendChild(canvas); + + const qrHide = document.createElement('a'); + qrHide.className = 'simplex-preview-qr-toggle'; + qrHide.textContent = 'Hide QR code'; + qrHide.href = '#'; + qrPopup.appendChild(qrHide); + container.appendChild(qrPopup); + + function toggleQr(e) { + e.preventDefault(); + if (qrPopup.style.display === 'none') { + qrPopup.style.display = 'flex'; + qrToggle.style.display = 'none'; + if (!canvas._rendered) { + canvas._rendered = true; + try { + var cs = getComputedStyle(container); + QRCode.toCanvas(canvas, channelLink, { + errorCorrectionLevel: 'M', + color: { + dark: cs.getPropertyValue('--sp-qr-fg').trim() || '#062D56', + light: cs.getPropertyValue('--sp-qr-bg').trim() || '#ffffff' + }, + width: 400, + margin: 1 + }).then(function() { + canvas.style.width = '200px'; + canvas.style.height = '200px'; + }).catch(function() { + qrPopup.style.display = 'none'; + qrToggle.style.display = 'none'; + }); + } catch(err) { + qrPopup.style.display = 'none'; + qrToggle.style.display = 'none'; + } + } + } else { + qrPopup.style.display = 'none'; + qrToggle.style.display = ''; + } + } + qrToggle.addEventListener('click', toggleQr); + qrHide.addEventListener('click', toggleQr); + + const copyAction = document.createElement('p'); + copyAction.className = 'simplex-preview-copy-action'; + const copyLink = document.createElement('a'); + copyLink.textContent = 'copy link'; + copyLink.addEventListener('click', function() { + navigator.clipboard.writeText(channelLink).then(function() { + copyLink.textContent = 'Copied!'; + setTimeout(function() { copyLink.textContent = 'copy link'; }, 2000); + }); + }); + copyAction.appendChild(document.createTextNode('Or ')); + copyAction.appendChild(copyLink); + copyAction.appendChild(document.createTextNode(' for desktop app')); + container.appendChild(copyAction); +} + +function renderMobileConversion(container, channelLink, showAppBadges) { + if (showAppBadges) { + renderAppBadges(container); + } + + const openBtn = document.createElement('a'); + openBtn.className = 'simplex-preview-open-btn'; + openBtn.textContent = 'Join in SimpleX Chat'; + appendSimplexLogo(openBtn); + openBtn.href = channelLink; + container.appendChild(openBtn); +} + + +function setupSecretToggles(container) { + container.addEventListener('click', (e) => { + const secret = e.target.closest('.secret'); + if (secret) secret.classList.toggle('visible'); + }); +} + +function renderMessages(container, messages, membersMap, channel) { + const hasAnySender = messages.some(function(m) { return m.sender; }); + let prevMsg = null; + let prevDate = null; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const nextMsg = i < messages.length - 1 ? messages[i + 1] : null; + + const msgDate = formatDateLabel(msg.ts); + if (msgDate !== prevDate) { + const dateSep = document.createElement('div'); + dateSep.className = 'simplex-preview-date-separator'; + dateSep.textContent = msgDate; + container.appendChild(dateSep); + prevDate = msgDate; + } + + const separation = getItemSeparation(msg, prevMsg); + const nextSeparation = getItemSeparation(nextMsg, msg); + const showAvatar = hasAnySender && (!prevMsg || msg.sender !== prevMsg.sender); + const showName = showAvatar; + const showTail = nextSeparation.largeGap; + + const member = msg.sender ? membersMap[msg.sender] : null; + const senderName = member ? member.displayName : channel.displayName; + const senderImage = member ? member.image : channel.image; + + const row = document.createElement('div'); + row.className = 'simplex-preview-msg-row' + (nextSeparation.largeGap ? ' has-gap' : ''); + + if (hasAnySender) { + if (showName) { + const nameDiv = document.createElement('div'); + nameDiv.className = 'simplex-preview-msg-name'; + nameDiv.textContent = senderName; + container.appendChild(nameDiv); + } + + if (showAvatar) { + const avatarImg = document.createElement('img'); + avatarImg.className = 'simplex-preview-msg-avatar'; + avatarImg.src = isDataImage(senderImage) ? senderImage : DEFAULT_AVATAR; + avatarImg.alt = senderName; + row.appendChild(avatarImg); + } else { + const spacer = document.createElement('div'); + spacer.className = 'simplex-preview-msg-avatar-placeholder'; + row.appendChild(spacer); + } + } + + const col = document.createElement('div'); + const bubble = renderBubble(msg, member, showTail, membersMap, channel); + col.appendChild(bubble); + + if (msg.reactions && msg.reactions.length > 0) { + col.appendChild(renderReactions(msg.reactions)); + } + + row.appendChild(col); + container.appendChild(row); + prevMsg = msg; + } +} + +function renderBubble(msg, member, showTail, membersMap, channel) { + const mc = msg.content; + const mediaOnly = (mc.type === 'image' || mc.type === 'video') && !mc.text && !msg.quote && !msg.forward; + const noTailContent = (mc.type === 'image' || mc.type === 'video' || mc.type === 'voice') && !mc.text; + const hasTail = showTail && !noTailContent; + + const bubble = document.createElement('div'); + bubble.className = 'simplex-preview-bubble' + (hasTail ? ' has-tail' : '') + (mediaOnly ? ' media-only' : ''); + + if (hasTail) { + const tail = document.createElement('div'); + tail.className = 'simplex-preview-bubble-tail'; + tail.innerHTML = tailSvg(); + bubble.appendChild(tail); + } + + const inner = document.createElement('div'); + inner.className = 'simplex-preview-bubble-inner'; + + if (msg.forward) { + const fwd = document.createElement('div'); + fwd.className = 'simplex-preview-forwarded-header'; + fwd.innerHTML = FORWARD_ICON_SVG + ' Forwarded'; + inner.appendChild(fwd); + } + + if (msg.quote) { + inner.appendChild(renderQuote(msg.quote, membersMap, channel)); + } + + switch (mc.type) { + case 'image': + renderImageContent(inner, mc, msg, mediaOnly); + break; + case 'video': + renderVideoContent(inner, mc, msg, mediaOnly); + break; + case 'link': + renderLinkContent(inner, mc, msg); + break; + case 'voice': + renderVoiceContent(inner, mc, msg); + break; + case 'file': + renderFileContent(inner, mc, msg); + break; + default: + renderTextContent(inner, msg); + break; + } + + bubble.appendChild(inner); + + if (mediaOnly) { + const overlay = document.createElement('div'); + overlay.className = 'simplex-preview-meta-overlay'; + if (msg.edited) overlay.innerHTML = 'edited '; + overlay.innerHTML += formatTime(msg.ts); + bubble.appendChild(overlay); + } + + return bubble; +} + +function renderQuote(quote, membersMap, channel) { + const quoteDiv = document.createElement('div'); + quoteDiv.className = 'simplex-preview-quote'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'simplex-preview-quote-content'; + + const ref = quote.msgRef; + let senderName = ''; + if (ref) { + if (ref.memberId) { + const quotedMember = membersMap[ref.memberId]; + senderName = quotedMember ? quotedMember.displayName : ''; + } else if (ref.sent) { + senderName = channel.displayName; + } + } + if (senderName) { + const sender = document.createElement('div'); + sender.className = 'simplex-preview-quote-sender'; + sender.textContent = senderName; + contentDiv.appendChild(sender); + } + + const textDiv = document.createElement('div'); + textDiv.className = 'simplex-preview-quote-text'; + textDiv.textContent = quote.content ? (quote.content.text || '') : ''; + contentDiv.appendChild(textDiv); + + quoteDiv.appendChild(contentDiv); + + if (quote.content) { + if ((quote.content.type === 'image' || quote.content.type === 'video') && isDataImage(quote.content.image)) { + const thumb = document.createElement('img'); + thumb.className = 'simplex-preview-quote-thumb'; + thumb.src = quote.content.image; + quoteDiv.appendChild(thumb); + } else if (quote.content.type === 'file') { + const icon = document.createElement('div'); + icon.className = 'simplex-preview-quote-file-icon'; + icon.innerHTML = FILE_ICON_SVG; + quoteDiv.appendChild(icon); + } else if (quote.content.type === 'voice') { + const icon = document.createElement('div'); + icon.className = 'simplex-preview-quote-file-icon'; + icon.innerHTML = VOICE_ICON_SVG; + quoteDiv.appendChild(icon); + } + } + + return quoteDiv; +} + +function classifyImage(img) { + const w = img.naturalWidth; + const h = img.naturalHeight; + img.classList.add(w * 0.97 <= h ? 'portrait' : 'landscape'); +} + +function renderImageContent(inner, mc, msg, mediaOnly) { + if (isDataImage(mc.image)) { + const img = document.createElement('img'); + img.className = 'simplex-preview-image'; + img.src = mc.image; + img.alt = 'Image'; + img.addEventListener('load', () => classifyImage(img)); + inner.appendChild(img); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); + } + if (mc.text) { + appendTextBlock(inner, msg); + } else if (!mediaOnly) { + appendMetaOnly(inner, msg); + } +} + +function renderVideoContent(inner, mc, msg, mediaOnly) { + if (isDataImage(mc.image)) { + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + const img = document.createElement('img'); + img.className = 'simplex-preview-image'; + img.src = mc.image; + img.alt = 'Video'; + img.addEventListener('load', () => classifyImage(img)); + wrapper.appendChild(img); + const dur = document.createElement('span'); + dur.style.cssText = 'position:absolute;bottom:6px;left:12px;color:#fff;font-size:12px;text-shadow:0 0 4px rgba(0,0,0,0.7),0 0 2px rgba(0,0,0,0.9);'; + dur.textContent = formatDuration(mc.duration || 0); + wrapper.appendChild(dur); + inner.appendChild(wrapper); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); + } + if (mc.text) { + appendTextBlock(inner, msg); + } else if (!mediaOnly) { + appendMetaOnly(inner, msg); + } +} + +function renderLinkContent(bubble, mc, msg) { + if (mc.preview) { + const card = document.createElement('div'); + card.className = 'simplex-preview-link-card'; + if (isDataImage(mc.preview.image)) { + const img = document.createElement('img'); + img.className = 'simplex-preview-link-card-image'; + img.src = mc.preview.image; + img.alt = mc.preview.title || ''; + card.appendChild(img); + } + const body = document.createElement('div'); + body.className = 'simplex-preview-link-card-body'; + if (mc.preview.title) { + const title = document.createElement('div'); + title.className = 'simplex-preview-link-card-title'; + title.textContent = mc.preview.title; + body.appendChild(title); + } + if (mc.preview.description) { + const desc = document.createElement('div'); + desc.className = 'simplex-preview-link-card-description'; + desc.textContent = mc.preview.description; + body.appendChild(desc); + } + if (mc.preview.uri) { + const uri = document.createElement('div'); + uri.className = 'simplex-preview-link-card-uri'; + uri.textContent = mc.preview.uri; + body.appendChild(uri); + } + card.appendChild(body); + bubble.appendChild(card); + } + appendTextBlock(bubble, msg); +} + +function renderVoiceContent(bubble, mc, msg) { + const voiceDiv = document.createElement('div'); + voiceDiv.className = 'simplex-preview-voice'; + voiceDiv.innerHTML = VOICE_ICON_SVG + ' ' + formatDuration(mc.duration || 0) + ''; + bubble.appendChild(voiceDiv); + if (mc.text) { + appendTextBlock(bubble, msg); + } else { + appendMetaOnly(bubble, msg); + } +} + +function renderFileContent(bubble, mc, msg) { + const fileDiv = document.createElement('div'); + fileDiv.className = 'simplex-preview-file-indicator'; + fileDiv.innerHTML = FILE_ICON_SVG; + const info = document.createElement('div'); + if (msg.file) { + const nameSpan = document.createElement('div'); + nameSpan.className = 'simplex-preview-file-name'; + nameSpan.textContent = msg.file.fileName; + info.appendChild(nameSpan); + const sizeSpan = document.createElement('div'); + sizeSpan.className = 'simplex-preview-file-size'; + sizeSpan.textContent = formatFileSize(msg.file.fileSize); + info.appendChild(sizeSpan); + } + fileDiv.appendChild(info); + bubble.appendChild(fileDiv); + if (mc.text) { + appendTextBlock(bubble, msg); + } else { + appendMetaOnly(bubble, msg); + } +} + +function renderTextContent(bubble, msg) { + appendTextBlock(bubble, msg); +} + +function appendTextBlock(bubble, msg) { + const textDiv = document.createElement('div'); + textDiv.className = 'simplex-preview-text'; + const meta = renderMetaHTML(msg); + if (msg.formattedText && msg.formattedText.length > 0) { + textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + } else { + textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; + } + bubble.appendChild(textDiv); +} + +function appendMetaOnly(bubble, msg) { + const metaDiv = document.createElement('div'); + metaDiv.style.cssText = 'padding: 0 8px 4px; text-align: right;'; + metaDiv.innerHTML = renderMetaHTML(msg); + bubble.appendChild(metaDiv); +} + +function renderMetaHTML(msg) { + let html = ''; + if (msg.edited) html += 'edited '; + html += formatTime(msg.ts); + html += ''; + return html; +} + +function renderReactions(reactions) { + const div = document.createElement('div'); + div.className = 'simplex-preview-reactions'; + for (const r of reactions) { + if (r.totalReacted < 1) continue; + const badge = document.createElement('span'); + badge.className = 'simplex-preview-reaction'; + const emoji = r.reaction && r.reaction.emoji ? r.reaction.emoji : '?'; + badge.appendChild(document.createTextNode(emoji)); + if (r.totalReacted > 1) { + const count = document.createElement('span'); + count.className = 'simplex-preview-reaction-count'; + count.textContent = r.totalReacted; + badge.appendChild(count); + } + div.appendChild(badge); + } + return div; +} + +function getItemSeparation(msg, prevMsg) { + if (!prevMsg || !msg) return { largeGap: true }; + const sameSender = msg.sender === prevMsg.sender; + if (!sameSender) return { largeGap: true }; + const t1 = new Date(prevMsg.ts).valueOf(); + const t2 = new Date(msg.ts).valueOf(); + if (Math.abs(t2 - t1) >= 60000) return { largeGap: true }; + return { largeGap: false }; +} + +function formatTime(ts) { + try { + const d = new Date(ts); + const h = d.getHours().toString().padStart(2, '0'); + const m = d.getMinutes().toString().padStart(2, '0'); + return h + ':' + m; + } catch(e) { + return ''; + } +} + +function formatDateLabel(ts) { + try { + const d = new Date(ts); + const now = new Date(); + const weekday = d.toLocaleDateString(undefined, { weekday: 'short' }); + const dayMonth = d.toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); + return weekday + ', ' + dayMonth; + } catch(e) { + return ''; + } +} + +function formatDuration(secs) { + const m = Math.floor(secs / 60); + const s = secs % 60; + return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0'); +} + +function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +document.querySelectorAll('[data-simplex-channel-preview]').forEach(initChannelPreview); + +})(); diff --git a/website/src/js/directory.js b/website/src/js/directory.jsc similarity index 77% rename from website/src/js/directory.js rename to website/src/js/directory.jsc index afaac1053f..6959cd41a5 100644 --- a/website/src/js/directory.js +++ b/website/src/js/directory.jsc @@ -1,3 +1,11 @@ +#include "simplex-lib.jsc" + +const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; + +// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; + +const simplexUsersGroup = 'SimpleX users group'; + (function() { if (!document.location.pathname.startsWith('/directory')) return; @@ -428,144 +436,4 @@ if (document.readyState === 'loading') { } else { initDirectory(); } - -function escapeHtml(text) { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/\n/g, "
"); -} - -function getSimplexLinkDescr(linkType) { - switch (linkType) { - case 'contact': return 'SimpleX contact address'; - case 'invitation': return 'SimpleX one-time invitation'; - case 'group': return 'SimpleX group link'; - case 'channel': return 'SimpleX channel link'; - case 'relay': return 'SimpleX relay link'; - default: return 'SimpleX link'; - } -} - -function viaHost(smpHosts) { - const first = smpHosts[0] ?? '?'; - return `via ${first}`; -} - -function isCurrentSite(uri) { - return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") -} - -function targetBlank(uri) { - return isCurrentSite(uri) ? '' : ' target="_blank"' -} - -function renderMarkdown(fts) { - let html = ''; - for (const ft of fts) { - const { format, text } = ft; - if (!format) { - html += escapeHtml(text); - continue; - } - try { - switch (format.type) { - case 'bold': - html += `${escapeHtml(text)}`; - break; - case 'italic': - html += `${escapeHtml(text)}`; - break; - case 'strikeThrough': - html += `${escapeHtml(text)}`; - break; - case 'snippet': - html += `${escapeHtml(text)}`; - break; - case 'secret': - html += `${escapeHtml(text)}`; - break; - case 'small': - html += `${escapeHtml(text)}`; - break; - case 'colored': - html += `${escapeHtml(text)}`; - break; - case 'uri': - let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; - html += `${escapeHtml(text)}`; - break; - case 'hyperLink': { - const { showText, linkUri } = format; - html += `${escapeHtml(showText ?? linkUri)}`; - break; - } - case 'simplexLink': { - const { showText, linkType, simplexUri, smpHosts } = format; - const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); - html += `${linkText} (${viaHost(smpHosts)})`; - break; - } - case 'command': - html += `${escapeHtml(text)}`; - break; - case 'mention': - html += `${escapeHtml(text)}`; - break; - case 'email': - html += `${escapeHtml(text)}`; - break; - case 'phone': - html += `${escapeHtml(text)}`; - break; - case 'unknown': - html += escapeHtml(text); - break; - default: - html += escapeHtml(text); - } - } catch(e) { - console.log(e); - html += escapeHtml(text); - } - } - return html; -} })(); - -const simplexDirectoryDataURL = 'https://directory.simplex.chat/data/'; - -// const simplexDirectoryDataURL = 'http://localhost:8080/directory-data/'; - -const simplexUsersGroup = 'SimpleX users group'; - -const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; - -const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; - -function platformSimplexUri(uri) { - if (isMobile.any()) return uri; - const res = uri.match(simplexAddressRegexp); - if (!res || !Array.isArray(res) || res.length < 3) return uri; - const linkType = res[1]; - const fragment = res[2]; - if (simplexShortLinkTypes.includes(linkType)) { - const queryIndex = fragment.indexOf('?'); - if (queryIndex === -1) return uri; - const hashPart = fragment.substring(0, queryIndex); - const queryStr = fragment.substring(queryIndex + 1); - const params = new URLSearchParams(queryStr); - const host = params.get('h'); - if (!host) return uri; - params.delete('h'); - let newFragment = hashPart; - const remainingParams = params.toString(); - if (remainingParams) newFragment += '?' + remainingParams; - return `https://${host}:/${linkType}#${newFragment}`; - } else { - return `https://simplex.chat/${linkType}#${fragment}`; - } -} diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc new file mode 100644 index 0000000000..ffec278ca2 --- /dev/null +++ b/website/src/js/simplex-lib.jsc @@ -0,0 +1,156 @@ +const isMobile = { + Android: () => navigator.userAgent.match(/Android/i), + iOS: () => navigator.userAgent.match(/iPhone|iPad|iPod/i), + any: () => navigator.userAgent.match(/Android|iPhone|iPad|iPod/i) +}; + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} + +function escapeAttr(text) { + return text + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +const SAFE_URI_SCHEME = /^(https?:|simplex:|mailto:|tel:)/i; + +function safeHref(uri) { + if (SAFE_URI_SCHEME.test(uri)) return escapeAttr(uri); + return escapeAttr(`javascript:void(alert('Potentially malicious link blocked:\\n'+${JSON.stringify(uri)}))`); +} + +function getSimplexLinkDescr(linkType) { + switch (linkType) { + case 'contact': return 'SimpleX contact address'; + case 'invitation': return 'SimpleX one-time invitation'; + case 'group': return 'SimpleX group link'; + case 'channel': return 'SimpleX channel link'; + case 'relay': return 'SimpleX relay link'; + default: return 'SimpleX link'; + } +} + +function viaHost(smpHosts) { + const first = smpHosts[0] ?? '?'; + return `via ${first}`; +} + +function isCurrentSite(uri) { + return uri.startsWith("https://simplex.chat") || uri.startsWith("https://www.simplex.chat") +} + +function targetBlank(uri) { + return isCurrentSite(uri) ? '' : ' target="_blank"' +} + +function renderMarkdown(fts) { + let html = ''; + for (const ft of fts) { + const { format, text } = ft; + if (!format) { + html += escapeHtml(text); + continue; + } + try { + switch (format.type) { + case 'bold': + html += `${escapeHtml(text)}`; + break; + case 'italic': + html += `${escapeHtml(text)}`; + break; + case 'strikeThrough': + html += `${escapeHtml(text)}`; + break; + case 'snippet': + html += `${escapeHtml(text)}`; + break; + case 'secret': + html += `${escapeHtml(text)}`; + break; + case 'small': + html += `${escapeHtml(text)}`; + break; + case 'colored': + html += `${escapeHtml(text)}`; + break; + case 'uri': { + let href = text.startsWith('http://') || text.startsWith('https://') || text.startsWith('simplex:/') ? text : 'https://' + text; + html += `${escapeHtml(text)}`; + break; + } + case 'hyperLink': { + const { showText, linkUri } = format; + html += `${escapeHtml(showText ?? linkUri)}`; + break; + } + case 'simplexLink': { + const { showText, linkType, simplexUri, smpHosts } = format; + const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); + html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; + break; + } + case 'command': + html += `${escapeHtml(text)}`; + break; + case 'mention': + html += `${escapeHtml(text)}`; + break; + case 'email': + html += `${escapeHtml(text)}`; + break; + case 'phone': + html += `${escapeHtml(text)}`; + break; + case 'unknown': + html += escapeHtml(text); + break; + default: + html += escapeHtml(text); + } + } catch(e) { + console.log(e); + html += escapeHtml(text); + } + } + return html; +} + +const simplexAddressRegexp = /^simplex:\/([a-z]+)#(.+)/i; + +const simplexShortLinkTypes = ["a", "c", "g", "i", "r"]; + +function platformSimplexUri(uri) { + if (isMobile.any()) return uri; + const res = uri.match(simplexAddressRegexp); + if (!res || !Array.isArray(res) || res.length < 3) return uri; + const linkType = res[1]; + const fragment = res[2]; + if (simplexShortLinkTypes.includes(linkType)) { + const queryIndex = fragment.indexOf('?'); + if (queryIndex === -1) return uri; + const hashPart = fragment.substring(0, queryIndex); + const queryStr = fragment.substring(queryIndex + 1); + const params = new URLSearchParams(queryStr); + const host = params.get('h'); + if (!host) return uri; + params.delete('h'); + let newFragment = hashPart; + const remainingParams = params.toString(); + if (remainingParams) newFragment += '?' + remainingParams; + return `https://${host}:/${linkType}#${newFragment}`; + } else { + return `https://simplex.chat/${linkType}#${fragment}`; + } +} diff --git a/website/web.sh b/website/web.sh index 9464982a45..49888f78aa 100755 --- a/website/web.sh +++ b/website/web.sh @@ -55,6 +55,10 @@ for lang in "${langs[@]}"; do echo "done $lang copying" done +for f in src/js/*.jsc; do + [ -f "$f" ] && cpp -P -traditional-cpp "$f" "${f%.jsc}.js" +done + npm run build for lang in "${langs[@]}"; do From 2d80b2e463fb4e999550574bbeb0a6b566a2b205 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:28:49 +0400 Subject: [PATCH 18/81] fix(webrtc): stop preview tracks when abandoning pre-connect call (#7074) --- packages/simplex-chat-webrtc/src/call.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 5f3d2bf332..8441560013 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -583,6 +583,8 @@ const processCommand = (function () { case "capabilities": console.log("starting outgoing call - capabilities") if (activeCall) endCall() + // Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here) + stopNotConnectedCall() let localStream: MediaStream | null = null try { @@ -623,7 +625,8 @@ const processCommand = (function () { if (activeCall) endCall() // It can be already defined on Android when switching calls (if the previous call was outgoing) - notConnectedCall = undefined + // Stop its preview tracks before clearing, otherwise camera/mic stay live + stopNotConnectedCall() inactiveCallMediaSources.mic = true inactiveCallMediaSources.camera = command.media == CallMediaType.Video inactiveCallMediaSourcesChanged(inactiveCallMediaSources) @@ -1444,6 +1447,14 @@ const processCommand = (function () { } } + // Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live. + function stopNotConnectedCall() { + if (notConnectedCall) { + notConnectedCall.localStream.getTracks().forEach((track) => track.stop()) + notConnectedCall = undefined + } + } + function resetVideoElements() { const videos = getVideoElements() if (!videos) return From 2d23c2f3921882e1746322710642e831bba9af4c Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Wed, 17 Jun 2026 11:28:14 +0000 Subject: [PATCH 19/81] 6.5.5: android 355, desktop 146, ios 335 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++---------- apps/multiplatform/gradle.properties | 8 +-- .../types/typescript/package.json | 2 +- packages/simplex-chat-nodejs/package.json | 4 +- .../simplex-chat-nodejs/src/download-libs.js | 2 +- .../src/simplex_chat/_version.py | 4 +- .../flatpak/chat.simplex.simplex.metainfo.xml | 22 ++++++++ 7 files changed, 60 insertions(+), 38 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e2915963e4..0739e9522d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -184,8 +184,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -563,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -733,8 +733,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -820,8 +820,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-3s3jwLbCMWj9Jo7I40UgTa.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.5.0-57WEoB2fiWaFgCB1XksYmP.a */, ); path = Libraries; sourceTree = ""; @@ -2077,7 +2077,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2102,7 +2102,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2127,7 +2127,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2152,7 +2152,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2169,11 +2169,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2189,11 +2189,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2214,7 +2214,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2229,7 +2229,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2251,7 +2251,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2266,7 +2266,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2288,7 +2288,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2314,7 +2314,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2339,7 +2339,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2366,7 +2366,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2393,7 +2393,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2408,7 +2408,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2427,7 +2427,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 334; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2442,7 +2442,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.4; + MARKETING_VERSION = 6.5.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index a2b63a5810..61c744bf86 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5.4 -android.version_code=353 +android.version_name=6.5.5 +android.version_code=355 android.bundle=false -desktop.version_name=6.5.4 -desktop.version_code=145 +desktop.version_name=6.5.5 +desktop.version_code=146 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index ad7ab04462..756e181307 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.8.0", + "version": "0.9.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 0dff1f7f1d..40ef106103 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "6.5.4", + "version": "6.5.5", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.8.0", + "@simplex-chat/types": "^0.9.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 72761a1ac5..4fad0f45a9 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v6.5.4'; +const RELEASE_TAG = 'v6.5.5'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 2ae4ce941e..77acd0e8b3 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "6.5.4" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "6.5.4" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "6.5.5" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.5" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 3f35d652fe..0d93315435 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,28 @@ + + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.5:

+

Public channels - speak freely!

+
    +
  • Reliability: many relays per channel.
  • +
  • Ownership: you can run your own relays.
  • +
  • Security: owners hold channel keys.
  • +
  • Privacy: for owners and subscribers.
  • +
+

Easier to invite your friends: we made connecting simpler for new users.

+

Safe web links:

+
    +
  • opt-in to send link previews.
  • +
  • use SOCKS proxy for previews (if enabled).
  • +
  • prevent hyperlink phishing.
  • +
  • remove link tracking.
  • +
+

Non-profit governance: to make SimpleX Network last.

+
+
https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html From bcd980127d73f45ad87cd9745d14d6ef7468858f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:11:04 +0000 Subject: [PATCH 20/81] docs: allow sign content messages in channels plan (#7049) --- plans/2026-06-04-channel-message-signing.md | 233 ++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 plans/2026-06-04-channel-message-signing.md diff --git a/plans/2026-06-04-channel-message-signing.md b/plans/2026-06-04-channel-message-signing.md new file mode 100644 index 0000000000..f3828018b9 --- /dev/null +++ b/plans/2026-06-04-channel-message-signing.md @@ -0,0 +1,233 @@ +# Plan: optional signing of channel content messages (`XMsgNew` / `XMsgUpdate`) + +## Goal / user problem + +In relay-based channels, content (`XMsgNew`) is forwarded by relays and is **not** signed today (only group-state events are — `requiresSignature`, `Protocol.hs:1251`), so a relay can forge or alter content attributed to a member. This feature lets a member *optionally* attach their member signature, so recipients holding the (signed) roster can verify authorship + integrity. + +Decisions: +- **UI: both** — device-stored default ("sign my channel messages", off) + per-send long-press override (mirrors custom disappearing-message TTL). +- **Default: off**, with an in-UI tradeoff explanation (signing = non-repudiable, transferable proof of authorship). +- **Recipient indicator: in scope** (iOS + Kotlin) — signing is useless if invisible to readers. +- **Event scope: `XMsgNew` + `XMsgUpdate` only**; edits reuse the original's setting. `XMsgReact`/`XMsgDel` stay unsigned in v1. + +## Prerequisites / sequencing + +Lands after #7017 (signed roster) and #7048 (roster over inline files; `GRMember` role). Neither merged yet (branch `f/allow-sign-new-msg`; `git log` tops at #7043). Dependency is specific: *verification* needs the sender's member public key, distributed via the roster; without it a signed message degrades to `MSSSignedNoKey` rather than `MSSVerified`. Integration tests must use the roster/channel setup from those PRs. + +**Line numbers are pre-rebase** (grounded against #7043); #7017/#7048 shift every anchor, so **re-locate by symbol**. The dependency PRs add no 6th `updateGroupChatItem` caller, but other branches are queued (`f/channel-comments`, `f/public-groups-members-in-roster`) — hence the caller re-check gate below. + +## What already exists (so the change stays small) + +Wire format, signing, verification, DB persistence, and CLI display are present and reused unchanged: +- **Send signing:** `groupMsgSigning` (`Internal.hs:1963`) → `createSndMessages` threads `Maybe MsgSigning` (`:1950`) → `createNewSndMessage` Ed25519-signs `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, storing `SignedMsg` in `SndMessage.signedMsg_` (`Store/Messages.hs:234`; `Messages.hs:1156`). +- **Wire:** `batchMessages` prepends the signature via `encodeBatchElement` (`Batch.hs:46,65`); relay groups always batch (`memberSendAction` → only `MSASendBatched` under `useRelays'`, `Internal.hs:2222,2228`). +- **Receive verify:** `withVerifiedMsg` (`Subscriber.hs:3469`) runs for all group messages (`:1004`, forwarded `:3431`); `XMsgNew_`/`XMsgUpdate_` ∉ `requiresSignature` ⇒ `signatureOptional` (`:3491`), so signed → `MSSVerified`/`MSSSignedNoKey`, unsigned → accepted. **No protocol-version bump.** +- **Sent-item persistence:** `createNewSndChatItem` sets `msgSigned = MSSVerified <$ signedMsg_` (`Store/Messages.hs:550`) — own item auto-marked, readable by the edit path. +- **Received-item persistence:** `createNewRcvChatItem` records `RcvMessage.msgSigned` (`Store/Messages.hs:565,567`); `CIMeta.msgSigned :: Maybe MsgSigStatus` (`Messages.hs:520`). +- **CLI:** `sigStatusStr` (`View.hs:388`) appends `" (signed)"` / `" (signed, no key to verify)"`. + +Missing: (1) the *decision* to sign content (`groupMsgSigning` returns `Nothing` for content today); (2) per-send plumbing from the API; (3) reuse on edit; (4) the §7 stale-badge fix; (5) the §5 anonymity gate (HIGH); (6) the apps. + +## Threat model + +Actors: member (sender), recipients, and **chat relays** that forward content + roster. Relays are untrusted for content authenticity. + +- **Forgery of member content.** Signing closes it for signed messages: relay lacks the Ed25519 key; signature binds `(publicGroupId, memberId, body)` — no forgery, cross-bind, or alteration. +- **Downgrade / stripping (residual, by design).** Optional signing lets a relay strip a signature and deliver unsigned. Absence of a badge is **not** proof of forgery — only *presence* of a verified badge is a guarantee. A future "required signing" group setting would close it; out of scope. +- **Stale-badge spoof on edits (fixed — §7).** An in-place edit must not keep a `verified` badge over content from an unsigned, relay-forged `XMsgUpdate`. +- **Publish-as-channel de-anonymization (structurally prevented — §5).** Channels allow "publish as the channel" (`showGroupAsSender`/`asGroup`): subscribers see a post as *from the channel*, not the specific owner (Design Objective 6, `docs/protocol/channels-overview.md:214`); today a relay revealing the owner is only a *deniable* leak (`channels-overview.md:~237`). `groupMsgSigning` (`Internal.hs:1963-1967`) is blind to `showGroupAsSender`, so it would sign with binding `(publicGroupId, ownerMemberId)`, broadcast on the wire even for `FwdChannel` (`encodeFwdElement` → `encodeBatchElement signedMsg_`, `Batch.hs:108`). A malicious relay sets the live-forward `fwdSender` freely (it is derived from stored `sentAsGroup`, `Store/Delivery.hs:158`), so every subscriber verifies it as `MSSVerified` — turning the deniable leak into **non-repudiable proof** of which owner authored an intentionally anonymous post; the device-default toggle would trigger this silently. For an anonymity property this must be structurally impossible: signing is never applied to as-channel content (§5), the app option is hidden for as-channel sends (§C), and a defense-in-depth guard keeps `encodeFwdElement` signature-free for `FwdChannel` (Edge cases). (`processContentItem:1302` is the *history* path and rebuilds content unsigned — not the vector.) +- **Non-repudiation (tradeoff, by design).** A verified signature is transferable proof of authorship — a deniability loss; hence opt-in/off-by-default with UI explanation. For *as-channel* posts the loss is unacceptable, not a tradeoff — hence the §5 exclusion. +- **What "verified" means.** Signed input is `encodeChatBinding CBGroup (publicGroupId, memberId) <> msgBody`, with `msgBody` embedding `sharedMsgId`, `MsgScope`, content (`Store/Messages.hs:242`). It proves **authorship + integrity + group/member/scope/message binding** — and nothing else: not `fwdBrokerTs` (relay-controlled, `Protocol.hs:382-387`), ordering, or completeness. Surface this in UI/help. +- **Signed content is still relay-suppressible.** `XMsgDel_` ∉ `requiresSignature` (`Protocol.hs:1252-1262`), so an unsigned relay-forged owner-attributed delete is accepted (role-based check vs. the relay-chosen author, `Subscriber.hs:~2269`). Pre-existing, within the relay's drop power; bounds signing's value (proves *what was said*, not that all is delivered). +- **Replay.** Binding covers `sharedMsgId` + `MsgScope`; cross-scope/group replay is blocked, same-message replay is a dedup duplicate. +- **Bad-signature spam (fail-closed, pre-existing).** Failed verification drops content with an `RGEMsgBadSignature` item per occurrence (`Subscriber.hs:3473-3475,3483`); a tampering relay can spam these. Inherited from state-event behavior. + +## Core changes (Haskell) + +### 1. Signable-content predicate + +`Protocol.hs`, next to `requiresSignature` (`:1251`): +```haskell +-- | Content events whose authorship a member may optionally prove by signing. +signableContent :: CMEventTag e -> Bool +signableContent = \case + XMsgNew_ -> True + XMsgUpdate_ -> True + _ -> False +``` + +### 2. Signing decision carries the opt-in + +Named type near `MsgSigning` (`Protocol.hs:426`) — not a bare `Bool`: +```haskell +-- | Whether opt-in content signing applies to this group send. +-- Independent of mandatory state-event signing (requiresSignature), +-- which always applies in relay groups regardless of this value. +data ContentSig = SignContent | DontSignContent + deriving (Eq, Show) +``` +Extend `groupMsgSigning` (`Internal.hs:1963`): +```haskell +groupMsgSigning :: ContentSig -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning +groupMsgSigning csig gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt + | useRelays' gInfo && shouldSign = + Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey + where + tag = toCMEventTag evt + shouldSign = requiresSignature tag || (csig == SignContent && signableContent tag) +groupMsgSigning _ _ _ = Nothing +``` +- `useRelays'`/`groupKeys = Just` guards unchanged: in non-relay groups or keyless members, `SignContent` is a no-op (`Nothing`). +- Mandatory state-event signing unaffected (`requiresSignature` branch preserved). + +### 3. Thread `ContentSig` through the send functions + +`groupMsgSigning` is called only in `sendGroupMessages_` (`Internal.hs:2134`) and `sendGroupMemberMessages` (`:1972`). Add a `ContentSig` param to `sendGroupMessages_` (`:2132`, used in `idsEvts`), `sendGroupMessages` (`:2100`, pass-through), `sendGroupMessage` (`:2088`, pass-through). Keep `sendGroupMessage'` (`:2094`) and `sendGroupMemberMessages` (`:1969`) unchanged by hardcoding `DontSignContent` internally. + +Behavior-preserving (all existing callers pass `DontSignContent`) ⇒ its own commit. Call sites to pass `DontSignContent` (grep-verified): +- `sendGroupMessages`: `Subscriber.hs:1370`; `Commands.hs:793,800,2778,2909`. +- `sendGroupMessage`: `Commands.hs:889,2690,3272,3812,3815,3819`. +- `sendGroupMessages_` direct: `Commands.hs:2826,3849`. + +The two variable-`ContentSig` sites are the feature (next commit): content send (`Commands.hs:4405`) and group edit (`Commands.hs:732`). + +### 4. API: per-send `sign` flag + +Add a field to `APISendMessages` (`Controller.hs:332`): +```haskell +| APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, signMessages :: Bool, composedMessages :: NonEmpty ComposedMessage} +``` +Parser (`Commands.hs:5006`), mirroring `liveMessageP`/`sendMessageTTLP`, defaulting off so old command strings still parse: +```haskell +"/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> signMessagesP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)) +-- with: signMessagesP = " sign=" *> onOffP <|> pure False (place after sendMessageTTLP, before " json ") +``` +Wire: `/_send live=.. ttl=.. sign=on|off json ...`. Per-send granularity (like `ttl`), not per-`ComposedMessage`. API boundary (app↔core, same bundle) ⇒ not a protocol-compat concern. + +### 5. Content send path + +`sendGroupContentMessages` (`Commands.hs:4366`) and `sendGroupContentMessages_` (`:4375`) gain a `ContentSig` param. `showGroupAsSender` is in scope at the send site (`:4405`); **as-channel posts are never signed** (anonymity gate — see threat model): +```haskell +let csig' = if showGroupAsSender then DontSignContent else csig +(msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients csig' chatMsgEvents +``` +This gate is structural (must live here, not only in UI); it also keeps the sender's own as-channel item unsigned and keeps §6 edit-reuse consistent. + +- `APISendMessages` handler (`:637-650`): `signMessages` → `SignContent`/`DontSignContent`, passed down (both `SRGroup` and `SRDirect`; direct ignores it — `sendContactContentMessages` doesn't sign). The `:4405` gate then forces `DontSignContent` for as-channel sends regardless of the flag. +- `APIReportMessage` (`:679`): `DontSignContent` (reports unsigned in v1). + +### 6. Edit / restore reuse (the `XMsgUpdate` requirement) + +Group edit, `Commands.hs:710-742`. Own sent item loaded with `CIMeta` at `:720`; add `msgSigned` to the pattern and reuse it: +```haskell +... meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender, msgSigned} +... +let reuseSig = if isJust msgSigned then SignContent else DontSignContent +SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSig event +``` +`msgSigned` is loaded via `mkCIMeta`/`toGroupChatItem` (`Store/Messages.hs:2412`); for own items it is `Just MSSVerified` iff signed (`createNewSndChatItem` stores only `MSSVerified <$ signedMsg_`, `:550`), so `isJust` is the right test. This makes an edit (including the recipient-deleted-restore case) signed exactly when the original was; it is automatically consistent with §5 (as-channel originals are never signed ⇒ edits stay unsigned). + +Direct edit (`:697-704`) and local edit (`:745`) need no change (never signed). + +### 7. Security fix: refresh `msg_signed` on in-place content update + +**Finding:** `updateGroupChatItem_` (`Store/Messages.hs:2755`) updates content/status/timed fields but **not `msg_signed`** (`UPDATE` at `:2760-2767`); `updatedChatItem` (`:2749`) carries the original `meta.msgSigned`. Today invisible (content never signed); once content is signed and badged, an in-place edit from an **unsigned, relay-forged `XMsgUpdate`** would keep a stale `MSSVerified` badge over attacker content. + +**Why pass it in:** the `MSSVerified` vs `MSSSignedNoKey` outcome is computed at receive by `withVerifiedMsg` and lives only on the chat item; the stored `messages` row holds signature bytes but not the verification *outcome*. So the status must come from receive-time `RcvMessage.msgSigned`, not be re-derived. + +**Fix (contained to the group helper):** add a `Maybe MsgSigStatus` param to `updateGroupChatItem` (`:2746`); after `let ci' = updatedChatItem …` (`:2749`) override `ci'`'s `meta.msgSigned`, and add `msg_signed = ?` to `updateGroupChatItem_`'s `UPDATE` (`:2755`/`:2760-2767`). `updateGroupChatItem_` is called *only* from `updateGroupChatItem` (grep), so this is self-contained. **Leave `updatedChatItem` (`:2544`) unchanged** — it serves the unsigned direct/local paths (`:2540`, `:3210`). + +All **five** callers pass an explicit value (no implicit "preserve"): +- `Commands.hs:738` (sender edit): `MSSVerified <$ signedMsg_` from the returned `SndMessage` (mirrors `:550`; equals the reused setting). +- `Subscriber.hs:2212` (recipient in-place edit — *the spoof path*): `msgSigned` from the handler's `RcvMessage msg`. Unsigned forged edit ⇒ `Nothing` ⇒ badge removed; verified ⇒ kept. +- `Subscriber.hs:2172` (recipient restore in-place, after `saveRcvChatItem'`): same `msgSigned` from `msg`. +- `Subscriber.hs:1152` (`mdeUpdatedCI` decryption-error marker): `Nothing` — local marker, badge correctly cleared. +- `Subscriber.hs:1509` (`upsertBusinessRequestItem` business-chat welcome): `Nothing` — never a relay channel, safely preserves `Nothing`. (Sibling direct path `:1480` uses `updateDirectChatItem'`, unaffected.) + +Net: signed status is set explicitly from the source of current content in every group create/update path, so a stale badge cannot exist. + +### 8. Paths deliberately left unsigned + +- Auto-reply welcome content (`Subscriber.hs:1267` `XMsgUpdate`, `:1269` `XMsgNew`) via `sendGroupMessage'` ⇒ `DontSignContent`. +- `XMsgReact` (`Commands.hs:889`), `XMsgDel` (`Commands.hs:792-799`): unsigned in v1. Asymmetry: a post is verifiable, its reactions/deletes are not — and a signed post is still relay-suppressible (threat model). Later, extending `signableContent` could let recipients reject unsigned deletes of signed posts. + +## App changes (iOS + Kotlin) + +### A. Decode the signature status +- **JSON tags:** core uses `enumJSON (dropPrefix "MSS")` ⇒ `MSSVerified → "verified"`, `MSSSignedNoKey → "signedNoKey"` (lower-cases first letter). **Not** the DB/text strings (`"verified"`/`"no_key"`). +- iOS: `enum MsgSigStatus: String, Decodable { case verified, signedNoKey }`; add `public var msgSigned: MsgSigStatus?` to `CIMeta` (`apps/ios/SimpleXChat/ChatTypes.swift:3721-3737`). +- Kotlin: `@Serializable enum class MsgSigStatus { @SerialName("verified") Verified, @SerialName("signedNoKey") SignedNoKey }`; add `val msgSigned: MsgSigStatus? = null` to `CIMeta` (`apps/multiplatform/.../model/ChatModel.kt:3434-3450`). +- Optional field ⇒ backward-safe decode of old core JSON. + +### B. Device preference (default off) +- iOS: `@AppStorage(DEFAULT_PRIVACY_SIGN_CHANNEL_MESSAGES) private var signChannelMessages = false` + toggle in `PrivacySettings.swift` (pattern: `protectScreen`, `:68-70`) with a non-repudiation footer. +- Kotlin: `val privacySignChannelMessages = mkBoolPreference(SHARED_PREFS_PRIVACY_SIGN_CHANNEL_MESSAGES, false)` (`SimpleXAPI.kt:314`; declarations near `:122-125`) + `SettingsPreferenceItem` in `PrivacySettings.kt` with explanation. +- App-side only (like `customDisappearingMessageTime`), not core `AppSettings`. + +### C. Composer option (per-send override) + thread `sign` to the API +- Change the send closure to `(_ ttl: Int?, _ sign: Bool?)` (iOS `SendMessageView.swift:21`; Kotlin `SendMsgView.kt:54`), `sign == nil` ⇒ use device default; composer passes effective `sign = override ?? default`. +- Long-press item next to "Disappearing message" (iOS `SendMessageView.swift:224-247`; Kotlin `SendMsgView.kt:198-209`): "Sign message" (default off) / "Send without signing" (default on). +- **Gate visibility** on relay channel + membership has a signing key + **not as-channel** (the UI half of §5 — never offer it for as-channel publication). If app `GroupInfo` lacks relay/key state, add a derived `memberSigningAvailable` boolean to its JSON; AND it with the composer's as-channel state. Mirror `timedMessageAllowed`. +- `apiSendMessages`: add `sign: Bool`, append `sign=on|off` — iOS `ChatCommand.apiSendMessages` (`AppAPITypes.swift:48`, encode `:239`) + `SimpleXAPI.swift:545`; Kotlin `CC.ApiSendMessages` (`SimpleXAPI.kt:3676`, encode `:3867`) + `SimpleXAPI.kt:1097`. + +### D. Recipient indicator +- Show a "signed by author" indicator when `meta.msgSigned == .verified` in the meta row: iOS `CIMetaView.swift` `ciMetaText` (`:93-160`); Kotlin `CIMetaView.kt` `CIMetaText` (`:67-115`) + update `reserveSpaceForMeta` (`:118-175`) for icon width. +- `signedNoKey`: show muted or nothing so it isn't read as `verified` (design). Surface the "verified ≠ timestamp/ordering/completeness" caveat (threat model) in help. +- Own signed items use the same indicator (core sets `MSSVerified` on signed sends). + +## Compatibility analysis +- **Protocol wire format:** unchanged; existing batch-element signature prefix. No `chatVRange` bump; pre-feature relay-capable peers verify/accept correctly. +- **API command:** `sign=` additive with default; app+core ship together. +- **DB:** no migration. `chat_items.msg_signed` exists (added `M20260222_chat_relays`; in both schema files; written by `createNewChatItem_:603`). +- **App JSON:** new optional `msgSigned` decodes as absent on older cores. + +## Edge cases, races, correctness +- **Member without keys** (`groupKeys = Nothing`): `groupMsgSigning` returns `Nothing` even with `SignContent` ⇒ silent unsigned send. UI gate should prevent offering it; document the silent degrade. +- **Non-relay groups:** `useRelays'` guard ⇒ never signed; UI must not offer it. +- **Live messages:** initial `XMsgNew` then repeated `XMsgUpdate`, each reusing the item's `msgSigned` ⇒ every increment signed. Extra cost per keystroke-batch; acceptable. +- **Separate (non-batched) path drops signatures** (`sndMessageMBR` uses raw `msgBody`, `Internal.hs:2199`, vs the batched path's `encodeBatchElement`). Never reached in relay groups (`memberSendAction` → `MSASendBatched`). Add a test-asserted invariant; optionally make `sndMessageMBR` use `encodeBatchElement signedMsg_` too, so routing changes can't silently drop channel signatures. +- **Defense-in-depth: no signature on `FwdChannel`.** `encodeFwdElement` (`Batch.hs:108`) includes `signedMsg_` unconditionally; §5 makes it `Nothing` for `FwdChannel` in normal flow. Add a guard/assertion that `encodeFwdElement` carries no signature when `fwdSender = FwdChannel`, so no future upstream path can reintroduce the de-anonymization. +- **History re-send strips signatures (badge non-determinism, by design).** Relay history catch-up rebuilds content via `prepareGroupMsg` into plain `XGrpMsgForward` events (`processContentItem`, `Internal.hs:1279-1305`) and lacks the private key ⇒ unsigned. So for the same message, a live-forward recipient sees a badge while a history-catch-up recipient does not. Graceful (absence ≠ forgery); document in UI/help and test. +- **Concurrency:** signing/verification are pure given keys; no new shared state. Send holds `withGroupLock`; receive update runs under existing receive-loop serialization. No new races. + +## Tests + +Protocol (`tests/ProtocolTests.hs`, extending `:112-312`): +- Round-trip signed `XMsgNew`/`XMsgUpdate` through `SignedMsg`; assert binding `CBGroup <> (publicGroupId, memberId)`; `verify` accepts the right key, rejects wrong key / altered body / altered binding. + +Integration (`tests/ChatTests/`, using `setupRelay`/`prepareChannel1Relay`/`createChannel1Relay`/`memberJoinChannel`, `Groups.hs:8621-8750`): +- **Sign + verify:** `sign=on` ⇒ recipient and sender items are `(signed)` (`sigStatusStr`). +- **Off / opt-out:** `sign=off`/default ⇒ no `(signed)`. +- **No key:** missing roster key ⇒ `(signed, no key to verify)` (`MSSSignedNoKey`). +- **Edit reuse:** signed message edit stays `(signed)`; unsigned stays unsigned. +- **Edit downgrade (security):** unsigned `XMsgUpdate` for a previously-signed item (forging-relay, cf. `ChatRelays.hs:220-230`) ⇒ badge **removed** (§7). +- **As-channel never signed (anonymity):** owner posts `as_group=on sign=on` ⇒ no item is `(signed)` and no signature on the wire/stored message (guards §5). +- **History downgrade:** live-forward recipient sees `(signed)`; later history-catch-up recipient sees the same message without it (Edge cases). +- **Forgery rejection:** mismatched-binding replay/fabrication ⇒ signature stripped / `RGEMsgBadSignature`. + +App: minimal decode test that `"verified"`/`"signedNoKey"` parse to the right enum on both platforms (guards the §A tag mismatch). + +## Commit / diff plan + +1. **Structural (behavior-preserving):** add `ContentSig`, `signableContent`, parameterize `groupMsgSigning` + the three send functions, update all callers with `DontSignContent`. Reviewable as "no behavior change". +2. **Security fix (independent, behavioral no-op today):** add `Maybe MsgSigStatus` to `updateGroupChatItem`, override `meta.msgSigned` after `updatedChatItem`, add `msg_signed` to `updateGroupChatItem_`'s `UPDATE`, update all five callers (§7). Until commit 3 every call passes `Nothing`/unchanged, so no observable change yet — but correct on its own, with a regression test that bites once signing exists. +3. **Feature behavior (core):** `APISendMessages` field + parser; content send and edit pass the real `ContentSig` (with the §5 as-channel gate); report path `DontSignContent`. +4. **App — decode + recipient indicator.** +5. **App — device preference + composer option + `apiSendMessages` wiring.** +6. **Tests** (protocol + integration) — may accompany commits 2/3. + +Each commit builds and passes tests independently (bisect/rollback). + +### Pre-implementation gates (after rebasing onto #7017 + #7048) +- **MUST:** the as-channel gate (`showGroupAsSender ⇒ DontSignContent`, §5) lives in the *core* send path, and the app option is hidden for as-channel sends (§C) — not UI-only. +- **MUST:** re-run `grep -rn 'updateGroupChatItem\b'` and confirm **every** caller passes an explicit `Maybe MsgSigStatus` — a missed caller silently re-introduces the §7 spoof. (Pre-rebase set: `Commands.hs:738`; `Subscriber.hs:1152,1509,2172,2212`.) +- **SHOULD:** re-run the `sendGroupMessages`/`sendGroupMessage`/`sendGroupMessages_` caller greps; only content-send and edit pass a variable `ContentSig`, all others `DontSignContent`. +- **SHOULD:** the three "verified"-meaning caveats (no timestamp/ordering; history downgrade; relay-suppressible) are surfaced in UI/help, and the history-downgrade test exists. + +## Out of scope / future +- Group-level "expected/required signing" owner setting (closes the optional-downgrade gap). +- Signing reactions/deletes; signing auto-reply content; verifiable reports (signed `MCReport`). + +## Open assumptions to confirm during implementation +- App `GroupInfo` exposes relay+key state for the UI gate, or a derived boolean is added to its JSON. +- Visual treatment of `signedNoKey` vs `verified`, and how to surface the "verified ≠ timestamp/ordering/completeness" caveat (threat model) in help. From 8dd888295dcd28acf75ac274962285ccbac2a994 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:06:24 +0100 Subject: [PATCH 21/81] website: add SimpleX Network News channel preview (#7087) * website: add SimpleX Network News channel preview * send relay domain/capability to channel owner on profile update * add channel ID * add top offset parameter * allow overscroll * better scroll * improve * fix promoted communities * use path for channel renderer without host * fix --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat/Library/Commands.hs | 12 +---------- src/Simplex/Chat/Library/Internal.hs | 16 ++++++++++++++ src/Simplex/Chat/Library/Subscriber.hs | 2 ++ website/src/_includes/navbar.html | 2 +- website/src/blog.html | 3 ++- website/src/js/channel-preview.jsc | 29 +++++++++++++++++++++++--- website/src/js/directory.jsc | 3 +-- website/src/news.html | 16 ++++++++++++++ 8 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 website/src/news.html diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 646377ac9c..df03a776fe 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4918,17 +4918,7 @@ runRelayGroupLinkChecks user = do else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () - sendRelayCapIfNeeded cxt gInfo - sendRelayCapIfNeeded cxt gInfo = do - ChatConfig {webPreviewConfig} <- asks config - let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig - sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) - when (currentWebDomain /= sentWebDomain) $ do - owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo - let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners - unless (null capableOwners) $ do - void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) - withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain + sendRelayCapIfNeeded user gInfo checkRelayInactiveGroups = do cxt <- chatStoreCxt ttl <- asks (relayInactiveTTL . config) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 325e552d44..0b19c5a261 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2126,6 +2126,22 @@ sendGroupMessage' user gInfo members chatMsgEvent = ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" +-- Relay advertises its current web preview capability to channel owners. +-- Idempotent: sends only when the configured web domain differs from what was last sent, and only to +-- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap). +sendRelayCapIfNeeded :: User -> GroupInfo -> CM () +sendRelayCapIfNeeded user gInfo = do + ChatConfig {webPreviewConfig} <- asks config + let currentWebDomain = (\WebPreviewConfig {webDomain} -> webDomain) <$> webPreviewConfig + sentWebDomain <- withStore' (`getRelaySentWebDomain` gInfo) + when (currentWebDomain /= sentWebDomain) $ do + cxt <- chatStoreCxt + owners <- withStore' $ \db -> getGroupOwners db cxt user gInfo + let capableOwners = filter (\m -> memberCurrent m && m `supportsVersion` relayWebCapVersion) owners + unless (null capableOwners) $ do + void $ sendGroupMessage' user gInfo capableOwners (XGrpRelayCap RelayCapabilities {webDomain = currentWebDomain}) + withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain + sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupMessages user gInfo scope asGroup members events = do -- TODO [knocking] send current profile to pending member after approval? diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index b948d7727b..671b7a53df 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3323,6 +3323,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (useRelays' g'') $ void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' + -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) + when (isRelay (membership g)) $ sendRelayCapIfNeeded user g pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope) diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 34ee893dd3..cec2aa0a01 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -148,7 +148,7 @@ - {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) %} + {% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('credits' not in page.url) and ('file' not in page.url) and ('links' not in page.url) and ('news' not in page.url) %}