From be1c4018cc37b8813977390de0fd2ad0870b67a6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 2 Apr 2025 07:57:18 +0000 Subject: [PATCH 01/72] core: group knocking main functionality (#5726) * docs: knocking rfc * types, schema wip * more comments * rename * more types * change types * wip compiles * construct snd scope * fix tests * hide output * sending * fix member filtration * plans * conversations store/api model draft * xgrpmemnew * rework types wip * wip * wip * remove tag * wip (compiles) * fix parsing * fix query * fixes, tests * plans, comments * fix member filtration * wip * protocol wip * protocol wip * fix * fix tests * fixes, test * fix (remove NotInHistory) * fix * fix tests * fix * output * fix tests * tests * gcsi in view * fix tests * plans * refactor, make scopes Maybe * remove groupChatFilter * fix test * change schema * refactor * rename * rename * rename * refactor * rename * remove scope from item direction * rework view * change apis * make api for cli * change text * refactor * fixes * comment * rename * refactor fmap/mapM * refactor includeInHistory * query plans * knocking: patch scope (#5796) * knocking: patch scope wip * more patches * schema * remove space * plans * fix test --------- Co-authored-by: Evgeny Poberezkin --- .../src/Directory/Events.hs | 2 +- .../src/Directory/Service.hs | 12 +- docs/rfcs/2025-03-07-group-knocking.md | 38 ++ simplex-chat.cabal | 1 + src/Simplex/Chat/Bot.hs | 2 +- src/Simplex/Chat/Controller.hs | 15 +- src/Simplex/Chat/Library/Commands.hs | 492 ++++++++++-------- src/Simplex/Chat/Library/Internal.hs | 250 ++++++--- src/Simplex/Chat/Library/Subscriber.hs | 484 ++++++++++------- src/Simplex/Chat/Messages.hs | 75 ++- src/Simplex/Chat/Protocol.hs | 49 +- src/Simplex/Chat/Store/Connections.hs | 5 +- src/Simplex/Chat/Store/Files.hs | 10 +- src/Simplex/Chat/Store/Groups.hs | 89 ++-- src/Simplex/Chat/Store/Messages.hs | 445 ++++++++++------ src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20250310_group_scope.hs | 54 ++ .../SQLite/Migrations/chat_query_plans.txt | 146 ++++-- .../Store/SQLite/Migrations/chat_schema.sql | 20 +- src/Simplex/Chat/Store/Shared.hs | 19 +- src/Simplex/Chat/Terminal/Output.hs | 6 +- src/Simplex/Chat/Types.hs | 41 +- src/Simplex/Chat/Types/Preferences.hs | 51 +- src/Simplex/Chat/Types/Shared.hs | 5 - src/Simplex/Chat/View.hs | 127 +++-- tests/ChatClient.hs | 10 + tests/ChatTests/ChatList.hs | 18 +- tests/ChatTests/Groups.hs | 482 ++++++++++++++--- tests/ChatTests/Profiles.hs | 4 +- tests/ChatTests/Utils.hs | 49 +- tests/ProtocolTests.hs | 28 +- 31 files changed, 2089 insertions(+), 944 deletions(-) create mode 100644 docs/rfcs/2025-03-07-group-knocking.md create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index ed4204abb7..a3b71156fb 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -70,7 +70,7 @@ crDirectoryEvent = \case CRJoinedGroupMember {groupInfo, member = m} | pending m -> Just $ DEPendingMember groupInfo m | otherwise -> Nothing - CRNewChatItems {chatItems = AChatItem _ _ (GroupChat g) ci : _} -> case ci of + CRNewChatItems {chatItems = AChatItem _ _ (GroupChat g _scopeInfo) ci : _} -> case ci of ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent (MCText t)} | pending m -> Just $ DEPendingMemberMsg g m (chatItemId' ci) t _ -> Nothing CRMemberRole {groupInfo, member, toRole} diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 575c7ca738..6cd35a40ca 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -464,7 +464,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName [] -> textMsg "" : _ -> textMsg img : _ -> MCImage "" $ ImageData img - sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just gmId) [(quotedId, MCText noticeText), (Nothing, mc)] + sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(quotedId, MCText noticeText), (Nothing, mc)] gmId = groupMemberId' m approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () @@ -473,9 +473,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ gmId = groupMemberId' m sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case - CRJoinedGroupMember {} -> do + CRJoinedGroupMember {member} -> do atomically $ TM.delete gmId $ pendingCaptchas env - logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g + if memberStatus member == GSMemPendingReview + then logInfo $ "Member " <> viewName displayName <> " accepted and pending review, group " <> tshow groupId <> ":" <> viewGroupName g + else logInfo $ "Member " <> viewName displayName <> " accepted, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected accept member response: " <> tshow r dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () @@ -486,7 +488,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just PendingCaptcha {captchaText, sentAt, attempts} | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 | matchCaptchaStr captchaText msgText -> do - sendComposedMessages_ cc (SRGroup groupId $ Just $ groupMemberId' m) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just $ groupMemberId' m)) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] approvePendingMember a g m | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts @@ -496,7 +498,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g rejectPendingMember rjctNotice = do let gmId = groupMemberId' m - sendComposedMessages cc (SRGroup groupId $ Just gmId) [MCText rjctNotice] + sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case CRUserDeletedMembers _ _ (_ : _) _ -> do atomically $ TM.delete gmId $ pendingCaptchas env diff --git a/docs/rfcs/2025-03-07-group-knocking.md b/docs/rfcs/2025-03-07-group-knocking.md new file mode 100644 index 0000000000..fedbbfaf72 --- /dev/null +++ b/docs/rfcs/2025-03-07-group-knocking.md @@ -0,0 +1,38 @@ +# Group knocking + +## Problem + +In v6.3 release we added an option to "manually" approve members before introducing to group, based on decision made via `acceptMember` chat hook. Currently it's not supported in UI clients, and only used for directory service bot captcha challenge. + +The goal of next improvement is to let: +- admins screen members before admitting to group, while not limiting communication with joining member to a single admin (and also removing the need for admin to be a highly available host of the group link); +- and group owners set up other arbitrary automated challenges or entry rules, while still being able to advertise groups in directory service. + +## Solution + +Group link host (further host), knowing group requires admin approval, would initially only introduce member pending approval to admins. Admins can connect with member for screening, meanwhile host would be forwarding messages as usual between connecting members. As a result of screening, pending member can either be removed, or approved by admins. + +Upon acceptance, for further member connections to not depend on availability of admins, host should not only forward acceptance message, but also introduce remaining members to now accepted joining member. Respectively, admins' clients should not introduce members for approved members who are not their invitees. + +For group owners to be able to set up alternative automated challenges, these are some possible alternatives: +- We could add a new role `Approver`, so that instead of adding all \[possibly human\] admins, host would initially introduce only approvers. +- It could be an orthogonal to role member setting (would require protocol extension). +- List of "approver" member IDs could be communicated to host client. + +### Implementation details draft + +Host needs to have knowledge whether to automatically accept, or only introduce admins/approvers. + +```sql +ALTER TABLE group_profiles ADD COLUMN approval TEXT; -- comma separated member IDs; null - automatic introduction + +-- or + +ALTER TABLE group_profiles ADD COLUMN approval INTEGER; -- if based on `Approver` role +``` + +Alternatively, a different extension of protocol could be done in order to communicate group approval rule from owner to host outside of group profile (special messages). + +Admins/approvers need to have separate conversation per pending member, requires adding scope to chat items. + +Host to have specific processing of forwarded `XGrpLinkAcpt` - continue introduction of remaining members. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f2b3582722..5a6a540335 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -231,6 +231,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes + Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 54e7baa194..bbfce15b3f 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -91,7 +91,7 @@ deleteMessage cc ct chatItemId = do r -> putStrLn $ "unexpected delete message response: " <> show r contactRef :: Contact -> ChatRef -contactRef = ChatRef CTDirect . contactId' +contactRef ct = ChatRef CTDirect (contactId' ct) Nothing printLog :: ChatController -> ChatLogLevel -> String -> IO () printLog cc level s diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 49ff263f6f..c187dc10ba 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -363,6 +363,8 @@ data ChatCommand | APIRemoveMembers {groupId :: GroupId, groupMemberIds :: Set GroupMemberId, withMessages :: Bool} | APILeaveGroup GroupId | APIListMembers GroupId + -- | APIDeleteGroupConversations GroupId (NonEmpty GroupConversationId) + -- | APIArchiveGroupConversations GroupId (NonEmpty GroupConversationId) | APIUpdateGroupProfile GroupId GroupProfile | APICreateGroupLink GroupId GroupMemberRole | APIGroupLinkMemberRole GroupId GroupMemberRole @@ -486,6 +488,7 @@ data ChatCommand | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName + | ListMemberSupportChats GroupName | APIListGroups UserId (Maybe ContactId) (Maybe String) | ListGroups (Maybe ContactName) (Maybe String) | UpdateGroupNames GroupName GroupProfile @@ -658,6 +661,9 @@ data ChatResponse | CRWelcome {user :: User} | CRGroupCreated {user :: User, groupInfo :: GroupInfo} | CRGroupMembers {user :: User, group :: Group} + | CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} + -- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]} + -- | CRGroupConversationsDeleted {user :: User, groupInfo :: GroupInfo, deletedGroupConversations :: [GroupConversation]} | CRContactsList {user :: User, contacts :: [Contact]} | CRUserContactLink {user :: User, contactLink :: UserContactLink} | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} @@ -749,6 +755,8 @@ data ChatResponse | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} + | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberAcceptedByOther {user :: User, groupInfo :: GroupInfo, acceptingMember :: GroupMember, member :: GroupMember} | CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} | CRMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool} @@ -898,16 +906,15 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False --- (Maybe GroupMemberId) can later be changed to GroupSndScope = GSSAll | GSSAdmins | GSSMember GroupMemberId data SendRef = SRDirect ContactId - | SRGroup GroupId (Maybe GroupMemberId) + | SRGroup GroupId (Maybe GroupChatScope) deriving (Eq, Show) sendToChatRef :: SendRef -> ChatRef sendToChatRef = \case - SRDirect cId -> ChatRef CTDirect cId - SRGroup gId _ -> ChatRef CTGroup gId + SRDirect cId -> ChatRef CTDirect cId Nothing + SRGroup gId scope -> ChatRef CTGroup gId scope data ChatPagination = CPLast Int diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 54d3cd9143..cf56fafd14 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -504,14 +504,14 @@ processChatCommand' vr = \case (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) pure $ CRApiChats user previews - APIGetChat (ChatRef cType cId) contentFilter pagination search -> withUser $ \user -> case cType of + APIGetChat (ChatRef cType cId scope_) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" (directChat, navInfo) <- withFastStore (\db -> getDirectChat db vr user cId pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do - (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId contentFilter pagination search) + (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search) pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo CTLocal -> do when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" @@ -535,29 +535,30 @@ processChatCommand' vr = \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) fwdItemId) + Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTDirect ctId Nothing) fwdItemId) Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> - Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) 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) _ -> pure Nothing APISendMessages sendRef live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case sendRef of SRDirect chatId -> do mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) - SRGroup chatId directMemId_ -> + SRGroup chatId gsScope -> withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do g <- getGroupInfo db vr user chatId (g,) <$> mapM (composedMessageReqMentions db user g) cms - sendGroupContentMessages user gInfo directMemId_ live itemTTL cmrs + sendGroupContentMessages user gInfo gsScope live itemTTL cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user - APISetChatTags (ChatRef cType chatId) tagIds -> withUser $ \user -> withFastStore' $ \db -> case cType of + APISetChatTags (ChatRef cType chatId scope) tagIds -> withUser $ \user -> withFastStore' $ \db -> case cType of CTDirect -> do updateDirectChatTags db chatId (maybe [] L.toList tagIds) CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId - CTGroup -> do + CTGroup | isNothing scope -> do updateGroupChatTags db chatId (maybe [] L.toList tagIds) CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId _ -> pure $ chatCmdError (Just user) "not supported" @@ -575,24 +576,16 @@ processChatCommand' vr = \case createNoteFolderContentItems user folderId (L.map composedMessageReq cms) APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> withGroupLock "reportMessage" gId $ do - (gInfo, ms) <- - withFastStore $ \db -> do - gInfo <- getGroupInfo db vr user gId - (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) - let ms' = filter compatibleModerator ms - mc = MCReport reportText reportReason + gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} - when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" - let numFileInvs = length $ filter memberCurrent ms' - sendGroupContentMessages_ user gInfo Nothing ms' numFileInvs False Nothing [composedMessageReq cm] - where - compatibleModerator GroupMember {activeConn, memberChatVRange} = - maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion + -- TODO [knocking] reports sent to support scope may be wrong + sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) 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 $ APIReportMessage gId reportedItemId reportReason "" - APIUpdateChatItem (ChatRef cType chatId) itemId live (UpdatedMessage mc mentions) -> withUser $ \user -> assertAllowedContent mc >> case cType of + 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) $ throwChatError $ CECommandError "mentions are not supported in this chat" ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId @@ -605,37 +598,41 @@ processChatCommand' vr = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - (SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + let event = XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) Nothing + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) let edited = itemLive /= Just True updateDirectChatItem' db user contactId ci (CISndMsgContent mc) edited live Nothing $ Just msgId - startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' + startUpdatedTimedItemThread user (ChatRef CTDirect contactId Nothing) ci ci' pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> withGroupLock "updateChatItem" chatId $ do - Group gInfo@GroupInfo {groupId, membership} ms <- withFastStore $ \db -> getGroup db vr user chatId - assertUserGroupRole gInfo GRAuthor + gInfo@GroupInfo {groupId, membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId + when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor let (_, ft_) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership ft_ then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do + -- TODO [knocking] check chat item scope? cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do + (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion let changed = mc /= oldMC if changed || fromMaybe False itemLive then do ciMentions <- withFastStore $ \db -> getCIMentions db user gInfo ft_ mentions - let mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions - -- TODO [knocking] send separately to pending approval member - SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + let msgScope = toMsgScope gInfo <$> chatScopeInfo + mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions + event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope + SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -643,9 +640,9 @@ processChatCommand' vr = \case let edited = itemLive /= Just True ci' <- updateGroupChatItem db user groupId ci (CISndMsgContent mc) edited live $ Just msgId updateGroupCIMentions db gInfo ci' ciMentions - startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') - else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) + startUpdatedTimedItemThread user (ChatRef CTGroup groupId scope) ci ci' + pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo) ci') + else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTLocal -> do @@ -662,7 +659,7 @@ processChatCommand' vr = \case _ -> throwChatError CEInvalidChatItemUpdate CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of + APIDeleteChatItem (ChatRef cType chatId scope) itemIds mode -> withUser $ \user -> case cType of CTDirect -> withContactLock "deleteChatItem" chatId $ do (ct, items) <- getCommandDirectChatItems user chatId itemIds case mode of @@ -680,18 +677,24 @@ processChatCommand' vr = \case else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds + -- TODO [knocking] check scope for all items? case mode of - CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime - CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime + CIDMInternal -> do + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + ts <- liftIO getCurrentTime + deleteGroupCIs user gInfo chatScopeInfo items True False Nothing ts + CIDMInternalMark -> do + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + ts <- liftIO getCurrentTime + markGroupCIsDeleted user gInfo chatScopeInfo items True Nothing ts CIDMBroadcast -> do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds - -- TODO [knocking] validate: only current members or only single pending approval member - mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items False + mapM_ (sendGroupMessages user gInfo Nothing recipients) events + delGroupChatItems user gInfo chatScopeInfo items False CTLocal -> do (nf, items) <- getCommandLocalChatItems user chatId itemIds deleteLocalCIs user nf items True False @@ -713,8 +716,10 @@ processChatCommand' vr = \case itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) 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 - delGroupChatItemsForMembers user gInfo ms items + let recipients = filter memberCurrent ms + delGroupChatItemsForMembers user gInfo Nothing recipients items APIArchiveReceivedReports gId -> withUser $ \user -> withFastStore $ \db -> do g <- getGroupInfo db vr user gId deleteTs <- liftIO getCurrentTime @@ -723,17 +728,20 @@ processChatCommand' vr = \case APIDeleteReceivedReports gId itemIds mode -> withUser $ \user -> withGroupLock "deleteReports" gId $ do (gInfo, items) <- getCommandGroupChatItems user gId itemIds unless (all isRcvReport items) $ throwChatError $ CECommandError "some items are not received reports" + -- TODO [knocking] scope can be different for each item if reports are from different members + -- TODO (currently we pass Nothing as scope which is wrong) case mode of - CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime - CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime + CIDMInternal -> deleteGroupCIs user gInfo Nothing items True False Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo Nothing items True Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo - delGroupChatItemsForMembers user gInfo ms items + let recipients = filter memberCurrent ms + delGroupChatItemsForMembers user gInfo Nothing recipients items where isRcvReport = \case CChatItem _ ChatItem {content = CIRcvMsgContent (MCReport {})} -> True _ -> False - APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> case cType of + 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 @@ -755,9 +763,11 @@ processChatCommand' vr = \case _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" CTGroup -> withGroupLock "chatItemReaction" chatId $ do - (Group g@GroupInfo {membership} ms, CChatItem md ci) <- withFastStore $ \db -> do - gr@(Group g _) <- getGroup db vr user chatId - (gr,) <$> getGroupCIWithReactions db user g itemId + -- TODO [knocking] check chat item scope? + (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do + g <- getGroupInfo db vr user chatId + (g,) <$> getGroupCIWithReactions db user g itemId + (chatScopeInfo, recipients) <- getGroupRecipients vr user g scope groupKnockingVersion case ci of ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} -> do unless (groupFeatureAllowed SGFReactions g) $ @@ -767,16 +777,15 @@ processChatCommand' vr = \case let GroupMember {memberId = itemMemberId} = chatItemMember g ci rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - -- TODO [knocking] send separately to pending approval member - SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) + SndMessage {msgId} <- sendGroupMessage user g scope recipients (XMsgReact itemSharedMId (Just itemMemberId) reaction add) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt liftIO $ getGroupCIReactions db g itemMemberId itemSharedMId let ci' = CChatItem md ci {reactions} - r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction + r = ACIReaction SCTGroup SMDSnd (GroupChat g chatScopeInfo) $ CIReaction CIGroupSnd ci' createdAt reaction pure $ CRChatItemReaction user add r - _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" + _ -> throwChatError $ CECommandError "invalid reaction" CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" @@ -791,7 +800,8 @@ processChatCommand' vr = \case CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} <- getGroupChatItem db user groupId itemId liftIO $ getReactionMembers db vr user groupId itemSharedMId reaction pure $ CRReactionMembers user memberReactions - APIPlanForwardChatItems (ChatRef fromCType fromChatId) itemIds -> withUser $ \user -> case fromCType of + -- TODO [knocking] forward from scope? + APIPlanForwardChatItems (ChatRef fromCType fromChatId _scope) itemIds -> withUser $ \user -> case fromCType of CTDirect -> planForward user . snd =<< getCommandDirectChatItems user fromChatId itemIds CTGroup -> planForward user . snd =<< getCommandGroupChatItems user fromChatId itemIds CTLocal -> planForward user . snd =<< getCommandLocalChatItems user fromChatId itemIds @@ -837,7 +847,8 @@ processChatCommand' vr = \case MCFile t -> t /= "" MCReport {} -> True MCUnknown {} -> True - APIForwardChatItems toChat@(ChatRef toCType toChatId) fromChat@(ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of + -- TODO [knocking] forward from / to scope + APIForwardChatItems toChat@(ChatRef toCType toChatId toScope) fromChat@(ChatRef fromCType fromChatId _fromScope) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do cmrs <- prepareForward user case L.nonEmpty cmrs of @@ -851,7 +862,7 @@ processChatCommand' vr = \case Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId - sendGroupContentMessages user gInfo Nothing False itemTTL cmrs' + sendGroupContentMessages user gInfo toScope False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -878,6 +889,7 @@ processChatCommand' vr = \case forwardName Contact {profile = LocalProfile {displayName, localAlias}} | localAlias /= "" = localAlias | otherwise = displayName + -- TODO [knocking] from scope? CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items @@ -977,7 +989,7 @@ processChatCommand' vr = \case pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId - APIChatRead chatRef@(ChatRef cType chatId) -> withUser $ \_ -> case cType of + APIChatRead chatRef@(ChatRef cType chatId _scope) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId ts <- liftIO getCurrentTime @@ -1002,7 +1014,8 @@ processChatCommand' vr = \case ok user CTContactRequest -> pure $ chatCmdError Nothing "not supported" CTContactConnection -> pure $ chatCmdError Nothing "not supported" - APIChatItemsRead chatRef@(ChatRef cType chatId) itemIds -> withUser $ \_ -> case cType of + -- TODO [knocking] read scope? + APIChatItemsRead chatRef@(ChatRef cType chatId _scope) itemIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId timedItems <- withFastStore' $ \db -> do @@ -1020,13 +1033,14 @@ processChatCommand' vr = \case CTLocal -> pure $ chatCmdError Nothing "not supported" CTContactRequest -> pure $ chatCmdError Nothing "not supported" CTContactConnection -> pure $ chatCmdError Nothing "not supported" - APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of + APIChatUnread (ChatRef cType chatId scope) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do ct <- getContact db vr user chatId liftIO $ updateContactUnreadChat db user ct unreadChat ok user - CTGroup -> do + -- TODO [knocking] set support chat as unread? + CTGroup | isNothing scope -> do withFastStore $ \db -> do Group {groupInfo} <- getGroup db vr user chatId liftIO $ updateGroupUnreadChat db user groupInfo unreadChat @@ -1037,7 +1051,7 @@ processChatCommand' vr = \case liftIO $ updateNoteFolderUnreadChat db user nf unreadChat ok user _ -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChat cRef@(ChatRef cType chatId) cdm -> withUser $ \user@User {userId} -> case cType of + APIDeleteChat cRef@(ChatRef cType chatId scope) cdm -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withFastStore $ \db -> getContact db vr user chatId filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct @@ -1076,7 +1090,7 @@ processChatCommand' vr = \case deleteAgentConnectionAsync user acId withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn - CTGroup -> do + CTGroup | isNothing scope -> do Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user chatId let GroupMember {memberRole = membershipMemRole} = membership let isOwner = membershipMemRole == GROwner @@ -1086,8 +1100,8 @@ processChatCommand' vr = \case withGroupLock "deleteChat group" chatId . procCmd $ do deleteCIFiles user filesInfo let doSendDel = memberActive membership && isOwner - -- TODO [knocking] send to pending approval members (move `memberCurrent` filter from sendGroupMessages_ to call sites) - when doSendDel . void $ sendGroupMessage' user gInfo members XGrpDel + recipients = filter memberCurrentOrPending members + when doSendDel . void $ sendGroupMessage' user gInfo recipients XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () @@ -1096,23 +1110,22 @@ processChatCommand' vr = \case withFastStore' $ \db -> deleteGroupMembers db user gInfo withFastStore' $ \db -> deleteGroup db user gInfo pure $ CRGroupDeletedUser user gInfo - CTLocal -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of + _ -> pure $ chatCmdError (Just user) "not supported" + APIClearChat (ChatRef cType chatId scope) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withFastStore $ \db -> getContact db vr 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 -> do + CTGroup | isNothing scope -> do gInfo <- withFastStore $ \db -> getGroupInfo db vr 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 forM_ membersToDelete $ \m -> withFastStore' $ \db -> deleteGroupMember db user m - pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) + pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo Nothing) CTLocal -> do nf <- withFastStore $ \db -> getNoteFolder db user chatId filesInfo <- withFastStore' $ \db -> getNoteFolderFileInfo db user nf @@ -1120,8 +1133,7 @@ processChatCommand' vr = \case withFastStore' $ \db -> deleteNoteFolderFiles db userId nf withFastStore' $ \db -> deleteNoteFolderCIs db user nf pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + _ -> pure $ chatCmdError (Just user) "not supported" APIAcceptContact incognito connReqId -> withUser $ \_ -> do userContactLinkId <- withFastStore $ \db -> getUserContactLinkIdByCReq db connReqId withUserContactLock "acceptContact" userContactLinkId $ do @@ -1181,7 +1193,7 @@ processChatCommand' vr = \case timed_ <- contactCITimed ct updateDirectChatItemView user ct chatItemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId) pure Nothing _ -> throwChatError . CECallState $ callStateTag callState APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> @@ -1271,13 +1283,13 @@ processChatCommand' vr = \case pure user' when (userId == uId') $ chatWriteVar currentUser $ Just (user :: User) {uiThemes} ok user' - APISetChatUIThemes (ChatRef cType chatId) uiThemes -> withUser $ \user -> case cType of + APISetChatUIThemes (ChatRef cType chatId scope) uiThemes -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do ct <- getContact db vr user chatId liftIO $ setContactUIThemes db user ct uiThemes ok user - CTGroup -> do + CTGroup | isNothing scope -> do withFastStore $ \db -> do g <- getGroupInfo db vr user chatId liftIO $ setGroupUIThemes db user g uiThemes @@ -1405,7 +1417,7 @@ processChatCommand' vr = \case currentTs <- liftIO getCurrentTime acceptConditions db condId opIds currentTs CRServerOperatorConditions <$> getServerOperators db - APISetChatTTL userId (ChatRef cType chatId) newTTL_ -> + 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 @@ -1419,13 +1431,13 @@ processChatCommand' vr = \case where getSetChatTTL db = case cType of CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ - CTGroup -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + CTGroup | isNothing scope -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ _ -> pure Nothing expireChat user globalTTL = do currentTs <- liftIO getCurrentTime case cType of CTDirect -> expireContactChatItems user vr globalTTL chatId - CTGroup -> + CTGroup | isNothing scope -> let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs in expireGroupChatItems user vr globalTTL createdAtCutoff chatId _ -> throwChatError $ CECommandError "not supported" @@ -1433,7 +1445,8 @@ processChatCommand' vr = \case chatRef <- getChatRef user chatName processChatCommand $ APISetChatTTL userId chatRef newTTL GetChatTTL chatName -> withUser' $ \user -> do - ChatRef cType chatId <- getChatRef user chatName + -- TODO [knocking] support scope in CLI apis + ChatRef cType chatId _ <- getChatRef user chatName ttl <- case cType of CTDirect -> withFastStore' (`getDirectChatTTL` chatId) CTGroup -> withFastStore' (`getGroupChatTTL` chatId) @@ -1468,7 +1481,7 @@ processChatCommand' vr = \case ReconnectServer userId srv -> withUserId userId $ \user -> do lift (withAgent' $ \a -> reconnectSMPServer a (aUserId user) srv) ok_ - APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of + APISetChatSettings (ChatRef cType chatId scope) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withFastStore $ \db -> do ct <- getContact db vr user chatId @@ -1477,7 +1490,7 @@ processChatCommand' vr = \case forM_ (contactConnId ct) $ \connId -> withAgent $ \a -> toggleConnectionNtfs a connId (chatHasNtfs chatSettings) ok user - CTGroup -> do + CTGroup | isNothing scope -> do ms <- withFastStore $ \db -> do Group _ ms <- getGroup db vr user chatId liftIO $ updateGroupSettings db user chatId chatSettings @@ -1560,8 +1573,9 @@ processChatCommand' vr = \case case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force - createInternalChatItem user (CDGroupSnd g) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m) Nothing - pure $ CRGroupMemberRatchetSyncStarted user g m cStats + (g', m', scopeInfo) <- liftIO $ mkGroupChatScope g m + createInternalChatItem user (CDGroupSnd g' scopeInfo) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m') Nothing + pure $ CRGroupMemberRatchetSyncStarted user g' m' cStats _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do ct@Contact {activeConn} <- withFastStore $ \db -> getContact db vr user contactId @@ -1766,8 +1780,8 @@ processChatCommand' vr = \case CPContactAddress (CAPContactViaAddress Contact {contactId}) -> processChatCommand $ APIConnectContactViaAddress userId incognito contactId _ -> processChatCommand $ APIConnect userId incognito (Just cReqUri) - DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) cdm - ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect + 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) ListContacts -> withUser $ \User {userId} -> @@ -1822,17 +1836,17 @@ processChatCommand' vr = \case contactId <- withFastStore $ \db -> getContactIdByName db user fromContactName forwardedItemId <- withFastStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId) (forwardedItemId :| []) Nothing + processChatCommand $ APIForwardChatItems toChatRef (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 - processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId) (forwardedItemId :| []) Nothing + processChatCommand $ APIForwardChatItems toChatRef (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 - processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId) (forwardedItemId :| []) Nothing + processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing SendMessage (ChatName cType name) msg -> withUser $ \user -> do let mc = MCText msg case cType of @@ -1924,7 +1938,7 @@ processChatCommand' vr = \case combineResults _ _ (Left e) = Left e createCI :: DB.Connection -> User -> UTCTime -> (Contact, SndMessage) -> IO () createCI db user createdAt (ct, sndMsg) = - void $ createNewSndChatItem db user (CDDirectSnd ct) Nothing sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False createdAt + void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False createdAt SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg @@ -1957,7 +1971,7 @@ processChatCommand' vr = \case -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing gInfo <- withFastStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile - let cd = CDGroupSnd gInfo + let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo pure $ CRGroupCreated user gInfo @@ -2025,18 +2039,39 @@ processChatCommand' vr = \case updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` (toView . CRChatError (Just user)) pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct - APIAcceptMember groupId gmId role -> withUser $ \user -> do + APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId - assertUserGroupRole gInfo GRAdmin - when (memberStatus m /= GSMemPendingApproval) $ throwChatError $ CECommandError "member is not pending approval" - case memberConn m of - Just mConn -> do - let msg = XGrpLinkAcpt role - void $ sendDirectMemberMessage mConn msg groupId - m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m role - introduceToGroup vr user gInfo m' - pure $ CRJoinedGroupMember user gInfo m' - _ -> throwChatError CEGroupMemberNotActive + assertUserGroupRole gInfo GRModerator + case memberStatus m of + GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve + case memberConn m of + Just mConn + | groupFeatureAllowed SGFNewMemberReview gInfo -> do + introduceToModerators vr user gInfo m + withFastStore' $ \db -> updateGroupMemberStatus db userId m GSMemPendingReview + let m' = m {memberStatus = GSMemPendingReview} + pure $ CRMemberAccepted user gInfo m' + | otherwise -> do + let msg = XGrpLinkAcpt role (Just $ memberId' m) + void $ sendDirectMemberMessage mConn msg groupId + introduceToRemaining vr user gInfo m {memberRole = role} + when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m + m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m GSMemConnected role + pure $ CRMemberAccepted user gInfo m' + Nothing -> throwChatError CEGroupMemberNotActive + GSMemPendingReview | memberCategory m /= GCInviteeMember -> do -- only other admins can review + let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter memberCurrent modMs + msg = XGrpLinkAcpt role (Just $ memberId' m) + void $ sendGroupMessage user gInfo scope rcpModMs' msg + m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m newMemberStatus role + pure $ CRMemberAccepted user gInfo m' + where + newMemberStatus = case memberConn m of + Just c | connReady c -> GSMemConnected + _ -> GSMemAnnounced + _ -> throwChatError $ CECommandError "member should be pending approval and invitee, or pending review and not invitee" APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId . procCmd $ do g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId @@ -2062,7 +2097,7 @@ processChatCommand' vr = \case | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin - anyPending' = anyPending || memberStatus == GSMemPendingApproval + anyPending' = anyPending || memberPending m in if | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending') @@ -2088,12 +2123,13 @@ processChatCommand' vr = \case Nothing -> pure ([], [], []) Just memsToChange' -> do let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' - (msgs_, _gsr) <- sendGroupMessages user gInfo members events + recipients = filter memberCurrent members + (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing recipients events let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) Nothing itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) - let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_ + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ pure (errs, changed, acis) where sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c @@ -2119,11 +2155,11 @@ processChatCommand' vr = \case selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) selectMembers = foldr' addMember ([], [], GRObserver, False, False) where - addMember m@GroupMember {groupMemberId, memberRole, memberStatus} (block, remaining, maxRole, anyAdmin, anyPending) + addMember m@GroupMember {groupMemberId, memberRole} (block, remaining, maxRole, anyAdmin, anyPending) | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin - anyPending' = anyPending || memberStatus == GSMemPendingApproval + anyPending' = anyPending || memberPending m in (m : block, remaining, maxRole', anyAdmin', anyPending') | otherwise = (block, m : remaining, maxRole, anyAdmin, anyPending) blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse @@ -2132,11 +2168,12 @@ processChatCommand' vr = \case Just blockMems' -> do let mrs = if blockFlag then MRSBlocked else MRSUnrestricted events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' - (msgs_, _gsr) <- sendGroupMessages user gInfo remainingMems events + recipients = filter memberCurrent remainingMems + (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients events let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) Nothing itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False when (length cis_ /= length blockMems) $ logError "blockMembers: blockMems and cis_ length mismatch" - let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_ + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ unless (null acis) $ toView $ CRNewChatItems user acis (errs, blocked) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updateGroupMemberBlocked db user gInfo mrs) blockMems) unless (null errs) $ toView $ CRChatErrors (Just user) errs @@ -2152,34 +2189,40 @@ processChatCommand' vr = \case APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId . procCmd $ do Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId - let (count, invitedMems, pendingMems, currentMems, maxRole, anyAdmin) = selectMembers members + let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers members memCount = S.size groupMemberIds when (count /= memCount) $ throwChatError CEGroupMemberNotFound when (memCount > 1 && anyAdmin) $ throwChatError $ CECommandError "can't remove multiple members when admins selected" assertUserGroupRole gInfo $ max GRAdmin maxRole (errs1, deleted1) <- deleteInvitedMems user invitedMems - (errs2, deleted2, acis2) <- deleteMemsSend user gInfo members currentMems - rs <- forM pendingMems $ \m -> deleteMemsSend user gInfo [m] [m] -- TODO [knocking] - let (errs3, deleted3, acis3) = concatTuples rs - acis = acis2 <> acis3 - errs = errs1 <> errs2 <> errs3 + let recipients = filter memberCurrent members + (errs2, deleted2, acis2) <- deleteMemsSend user gInfo Nothing recipients currentMems + (errs3, deleted3, acis3) <- + foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], []) pendingApprvMems + let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members + (errs4, deleted4, acis4) <- + foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], []) pendingRvwMems + let acis = acis2 <> acis3 <> acis4 + errs = errs1 <> errs2 <> errs3 <> errs4 + deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 unless (null acis) $ toView $ CRNewChatItems user acis unless (null errs) $ toView $ CRChatErrors (Just user) errs - when withMessages $ deleteMessages user gInfo $ currentMems <> pendingMems - pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2 <> deleted3) withMessages -- same order is not guaranteed + when withMessages $ deleteMessages user gInfo deleted + pure $ CRUserDeletedMembers user gInfo deleted withMessages -- same order is not guaranteed where - selectMembers :: [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) - selectMembers = foldl' addMember (0, [], [], [], GRObserver, False) + selectMembers :: [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldl' addMember (0, [], [], [], [], GRObserver, False) where - addMember acc@(n, invited, pending, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole} + addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole} | groupMemberId `S.member` groupMemberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin n' = n + 1 in case memberStatus of - GSMemInvited -> (n', m : invited, pending, current, maxRole', anyAdmin') - GSMemPendingApproval -> (n', invited, m : pending, current, maxRole', anyAdmin') - _ -> (n', invited, pending, m : current, maxRole', anyAdmin') + GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin') + GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin') + GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin') + _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin') | otherwise = acc deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) deleteInvitedMems user memsToDelete = do @@ -2189,18 +2232,24 @@ processChatCommand' vr = \case delMember db m = do deleteGroupMember db user m pure m {memberStatus = GSMemRemoved} - deleteMemsSend :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) - deleteMemsSend user gInfo sendToMems memsToDelete = case L.nonEmpty memsToDelete of + deletePendingMember :: ([ChatError], [GroupMember], [AChatItem]) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem]) + deletePendingMember (accErrs, accDeleted, accACIs) user gInfo recipients m = do + (m', scopeInfo) <- liftIO $ mkMemberSupportChatInfo m + (errs, deleted, acis) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] + pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs) + deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + deleteMemsSend user gInfo chatScopeInfo recipients memsToDelete = case L.nonEmpty memsToDelete of Nothing -> pure ([], [], []) Just memsToDelete' -> do - let events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' - (msgs_, _gsr) <- sendGroupMessages user gInfo sendToMems events + let chatScope = toChatScope <$> chatScopeInfo + events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' + (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope recipients events let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) Nothing itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData Nothing False when (length cis_ /= length memsToDelete) $ logError "deleteCurrentMems: memsToDelete and cis_ length mismatch" deleteMembersConnections' user memsToDelete True (errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) - let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_ + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) $ rights cis_ pure (errs, deleted, acis) where sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c @@ -2214,26 +2263,31 @@ processChatCommand' vr = \case deleteMessages user gInfo@GroupInfo {membership} ms | groupFeatureMemberAllowed SGFFullDelete membership gInfo = deleteGroupMembersCIs user gInfo ms membership | otherwise = markGroupMembersCIsDeleted user gInfo ms membership - concatTuples :: [([a], [b], [c])] -> ([a], [b], [c]) - concatTuples xs = (concat as, concat bs, concat cs) - where (as, bs, cs) = unzip3 xs APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId . procCmd $ do cancelFilesInProgress user filesInfo - -- TODO [knocking] send to pending approval members (move `memberCurrent` filter from sendGroupMessages_ to call sites) - msg <- sendGroupMessage' user gInfo members XGrpLeave - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] + let recipients = filter memberCurrentOrPending members + msg <- sendGroupMessage' user gInfo recipients XGrpLeave + (gInfo', scopeInfo) <- liftIO $ mkLocalGroupChatScope gInfo + ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) + toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] -- TODO delete direct connections that were unused - deleteGroupLinkIfExists user gInfo + deleteGroupLinkIfExists user gInfo' -- member records are not deleted to keep history deleteMembersConnections' user members True withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft - pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} + pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> withUser $ \user -> CRGroupMembers user <$> withFastStore (\db -> getGroup db vr 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 + -- ok_ -- CRGroupConversationsArchived + -- APIArchiveGroupConversations groupId _gcId -> withUser $ \user -> do + -- _gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + -- ok_ -- CRGroupConversationsDeleted AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole @@ -2253,13 +2307,19 @@ processChatCommand' vr = \case processChatCommand $ APILeaveGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) (CDMFull True) + processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) ClearGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIClearChat (ChatRef CTGroup groupId) + processChatCommand $ APIClearChat (ChatRef CTGroup groupId Nothing) ListMembers gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId + ListMemberSupportChats gName -> withUser $ \user -> do + gInfo <- withFastStore $ \db -> getGroupInfoByName db vr user gName + -- TODO [knocking] delete all support chats (chat items) if role is lowered? + assertUserGroupRole gInfo GRModerator + supportMems <- withFastStore' $ \db -> getSupportMembers db vr user gInfo + pure $ CRMemberSupportChats user gInfo supportMems APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> CRGroupsList user <$> withFastStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do @@ -2354,7 +2414,7 @@ processChatCommand' vr = \case processChatCommand $ APISendMessages (SRGroup groupId Nothing) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand $ APIClearChat (ChatRef CTLocal folderId) + processChatCommand $ 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) @@ -2391,7 +2451,7 @@ processChatCommand' vr = \case SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName case chatRef of - ChatRef CTLocal folderId -> processChatCommand $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] + ChatRef CTLocal folderId _ -> processChatCommand $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] _ -> withSendRef chatRef $ \sendRef -> processChatCommand $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName @@ -2428,18 +2488,21 @@ processChatCommand' vr = \case | otherwise -> do fileAgentConnIds <- cancelSndFile user ftm fts True deleteAgentConnectionsAsync user fileAgentConnIds - withFastStore (\db -> liftIO $ lookupChatRefByFileId db user fileId) >>= \case - Nothing -> pure () - Just (ChatRef CTDirect contactId) -> do + cref_ <- withFastStore' $ \db -> lookupChatRefByFileId db user fileId + aci_ <- withFastStore $ \db -> lookupChatItemByFileId db vr 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 void . sendDirectContactMessage user contact $ XFileCancel sharedMsgId - Just (ChatRef CTGroup groupId) -> do - (Group gInfo ms, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getSharedMsgIdByFileId db userId fileId - -- TODO [knocking] send separately to pending approval member - void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId - Just _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" - ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId - pure $ CRSndFileCancelled user ci ftm fts + 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, recipients) <- getGroupRecipients vr user gInfo scope 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" where fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} = s == FSCancelled || (s == FSComplete && isNothing xftpSndFile) @@ -2615,17 +2678,18 @@ processChatCommand' vr = \case ok_ = pure $ CRCmdOk Nothing ok = pure . CRCmdOk . Just getChatRef :: User -> ChatName -> CM ChatRef - getChatRef user (ChatName cType name) = - ChatRef cType <$> case cType of + getChatRef user (ChatName cType name) = do + chatId <- case cType of CTDirect -> withFastStore $ \db -> getContactIdByName db user name CTGroup -> withFastStore $ \db -> getGroupIdByName db user name CTLocal | name == "" -> withFastStore (`getUserNoteFolderId` user) | otherwise -> throwChatError $ CECommandError "not supported" _ -> throwChatError $ CECommandError "not supported" + pure $ ChatRef cType chatId Nothing getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) getChatRefAndMentions user cName msg = do - chatRef@(ChatRef cType chatId) <- getChatRef user cName + chatRef@(ChatRef cType chatId _) <- getChatRef user cName (chatRef,) <$> case cType of CTGroup -> withFastStore' $ \db -> getMessageMentions db user chatId msg _ -> pure [] @@ -2660,13 +2724,13 @@ processChatCommand' vr = \case withFastStore' $ \db -> setConnectionVerified db user connId Nothing pure $ CRConnectionVerified user False code' getSentChatItemIdByText :: User -> ChatRef -> Text -> CM Int64 - getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of + getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId _scope) msg = case cType of CTDirect -> withFastStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd msg CTGroup -> withFastStore $ \db -> getGroupChatItemIdByText db user cId (Just localDisplayName) msg CTLocal -> withFastStore $ \db -> getLocalChatItemIdByText db user cId SMDSnd msg _ -> throwChatError $ CECommandError "not supported" getChatItemIdByText :: User -> ChatRef -> Text -> CM Int64 - getChatItemIdByText user (ChatRef cType cId) msg = case cType of + getChatItemIdByText user (ChatRef cType cId _scope) msg = case cType of CTDirect -> withFastStore $ \db -> getDirectChatItemIdByText' db user cId msg CTGroup -> withFastStore $ \db -> getGroupChatItemIdByText' db user cId msg CTLocal -> withFastStore $ \db -> getLocalChatItemIdByText' db user cId msg @@ -2828,15 +2892,18 @@ processChatCommand' vr = \case GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} <- withStore $ \db -> getGroupMemberByMemberId db vr user g businessId let p'' = p' {displayName, fullName, image} :: GroupProfile - -- TODO [knocking] send to pending approval members (move `memberCurrent` filter from sendGroupMessages_ to call sites) - void $ sendGroupMessage user g' oldMs (XGrpInfo p'') + recipients = filter memberCurrentOrPending oldMs + void $ sendGroupMessage user g' Nothing recipients (XGrpInfo p'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' - sendGroupMessage user g' newMs $ XGrpPrefs ps' - Nothing -> sendGroupMessage user g' ms (XGrpInfo p') - let cd = CDGroupSnd g' + recipients = filter memberCurrentOrPending newMs + sendGroupMessage user g' Nothing recipients $ XGrpPrefs ps' + Nothing -> do + let recipients = filter memberCurrentOrPending ms + sendGroupMessage user g' Nothing recipients (XGrpInfo p') + let cd = CDGroupSnd g' Nothing unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g') ci] + toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g' Nothing) ci] createGroupFeatureChangedItems user cd CISndGroupFeature g g' pure $ CRGroupUpdated user g g' Nothing checkValidName :: GroupName -> CM () @@ -2851,16 +2918,14 @@ processChatCommand' vr = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItemsForMembers :: User -> GroupInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM ChatResponse - delGroupChatItemsForMembers user gInfo ms items = do + delGroupChatItemsForMembers :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM ChatResponse + delGroupChatItemsForMembers user gInfo chatScopeInfo ms items = do assertDeletable gInfo items assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds - -- TODO [knocking] validate: only current members or only single pending approval member, - -- TODO or prohibit pending approval members (only moderation and reports use this) - mapM_ (sendGroupMessages user gInfo ms) events - delGroupChatItems user gInfo items True + mapM_ (sendGroupMessages_ user gInfo ms) events + delGroupChatItems user gInfo chatScopeInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items' = @@ -2880,16 +2945,16 @@ processChatCommand' vr = \case CIGroupRcv GroupMember {memberId} -> (msgId, memberId) CIGroupSnd -> (msgId, membershipMemId) - delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> CM ChatResponse - delGroupChatItems user gInfo@GroupInfo {membership} items moderation = do + delGroupChatItems :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Bool -> CM ChatResponse + delGroupChatItems user gInfo@GroupInfo {membership} chatScopeInfo items moderation = do deletedTs <- liftIO getCurrentTime when moderation $ do ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci membership deletedTs) unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds True (Just membership) let m = if moderation then Just membership else Nothing if groupFeatureMemberAllowed SGFFullDelete membership gInfo - then deleteGroupCIs user gInfo items True False m deletedTs - else markGroupCIsDeleted user gInfo items True m deletedTs + then deleteGroupCIs user gInfo chatScopeInfo items True False m deletedTs + else markGroupCIsDeleted user gInfo chatScopeInfo items True m deletedTs updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> @@ -2968,7 +3033,7 @@ processChatCommand' vr = \case ci <- saveSndChatItem' user (CDDirectSnd ct) msg content Nothing Nothing Nothing timed_ False toView $ CRNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) drgRandomBytes :: Int -> CM ByteString drgRandomBytes n = asks random >>= atomically . C.randomBytes n privateGetUser :: UserId -> CM User @@ -3033,7 +3098,7 @@ processChatCommand' vr = \case GroupInfo {chatSettings} <- getGroupInfo db vr user gId pure (gId, chatSettings) _ -> throwChatError $ CECommandError "not supported" - processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings + processChatCommand $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings connectPlan :: User -> AConnectionRequestUri -> CM ConnectionPlan connectPlan user (ACR SCMInvitation (CRInvitationUri crData e2e)) = do withFastStore' (\db -> getConnectionEntityByConnReq db vr user cReqSchemas) >>= \case @@ -3118,7 +3183,7 @@ processChatCommand' vr = \case timed_ <- contactCITimed ct updateDirectChatItemView user ct itemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, itemId) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, itemId) _ -> pure () -- prohibited assertAllowedContent :: MsgContent -> CM () assertAllowedContent = \case @@ -3150,11 +3215,11 @@ processChatCommand' vr = \case msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch" - r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) Nothing itemsData timed_ live + r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live processSendErrs user r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) deleteAt + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) deleteAt pure $ CRNewChatItems user (map (AChatItem SCTDirect SMDSnd (DirectChat ct)) cis) where setupSndFileTransfers :: CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) @@ -3169,8 +3234,8 @@ processChatCommand' vr = \case prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do case (quotedItemId, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId @@ -3178,7 +3243,7 @@ processChatCommand' vr = \case let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) @@ -3186,29 +3251,27 @@ processChatCommand' vr = \case quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwError SEInvalidQuote - sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupMemberId -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user gInfo@GroupInfo {membership} directMemId_ live itemTTL cmrs = do + sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo scope live itemTTL cmrs = do assertMultiSendable live cmrs - (ms, numFileInvs, notInHistory_) <- case directMemId_ of - Nothing -> do - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - pure (ms, length $ filter memberCurrent ms, Nothing) - Just dmId -> do - when (dmId == groupMemberId' membership) $ throwChatError $ CECommandError "cannot send to self" - dm <- withFastStore $ \db -> getGroupMemberById db vr user dmId - unless (memberStatus dm == GSMemPendingApproval) $ throwChatError $ CECommandError "cannot send directly to member not pending approval" - pure ([dm], 1, Just NotInHistory) - sendGroupContentMessages_ user gInfo notInHistory_ ms numFileInvs live itemTTL cmrs - sendGroupContentMessages_ :: User -> GroupInfo -> Maybe NotInHistory -> [GroupMember] -> Int -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} notInHistory_ ms numFileInvs live itemTTL cmrs = do - -- TODO [knocking] pass GroupSndScope? - let allowedRole = case ms of - [m] | memberCategory m == GCHostMember && memberStatus membership == GSMemPendingApproval -> Nothing - _ -> Just GRAuthor + (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope modsCompatVersion + sendGroupContentMessages_ user gInfo scope chatScopeInfo recipients live itemTTL cmrs + where + hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs + modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion + sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope chatScopeInfo recipients live itemTTL cmrs = do forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages where + allowedRole :: Maybe GroupMemberRole + allowedRole = case scope of + Nothing -> Just GRAuthor + Just (GCSMemberSupport Nothing) + | memberPending membership -> Nothing + | otherwise -> Just GRAuthor + Just (GCSMemberSupport (Just _gmId)) -> Just GRModerator assertGroupContentAllowed :: CM () assertGroupContentAllowed = case findProhibited (L.toList cmrs) of @@ -3220,36 +3283,37 @@ processChatCommand' vr = \case foldr' (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership mc ft fileSource True <|> acc) Nothing - processComposedMessages :: CM ChatResponse + processComposedMessages :: CM ChatResponse processComposedMessages = do - (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers numFileInvs + (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo ms chatMsgEvents + (msgs_, gsr) <- sendGroupMessages user gInfo Nothing recipients chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo) notInHistory_ itemsData timed_ live + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ processSendErrs user r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> - startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) deleteAt - pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) cis) + startProximateTimedItemThread user (ChatRef CTGroup groupId scope, chatItemId' ci) deleteAt + pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) cis) where setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd))) setupSndFileTransfers n = forM cmrs $ \(ComposedMessage {fileSource = file_}, _, _, _) -> case file_ of Just file -> do fileSize <- checkSndFile file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup))) prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, ciMentions), fInv_) -> - let mentions = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions - in prepareGroupMsg db user gInfo mc mentions quotedItemId itemForwarded fInv_ timed_ live + let msgScope = toMsgScope gInfo <$> chatScopeInfo + mentions = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions + in prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId itemForwarded fInv_ timed_ live createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -3400,8 +3464,8 @@ processChatCommand' vr = \case CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId) withSendRef :: ChatRef -> (SendRef -> CM ChatResponse) -> CM ChatResponse withSendRef chatRef a = case chatRef of - ChatRef CTDirect cId -> a $ SRDirect cId - ChatRef CTGroup gId -> a $ SRGroup gId Nothing + ChatRef CTDirect cId _ -> a $ SRDirect cId + ChatRef CTGroup gId scope -> a $ SRGroup gId scope _ -> throwChatError $ CECommandError "not supported" protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) @@ -3942,6 +4006,8 @@ chatCommandP = "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)), "/_leave #" *> (APILeaveGroup <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal), + -- "/_archive conversations #" *> (APIArchiveGroupConversations <$> A.decimal <*> _strP), + -- "/_delete conversations #" *> (APIDeleteGroupConversations <$> A.decimal <*> _strP), "/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP), "/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP), "/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP), @@ -4033,6 +4099,7 @@ chatCommandP = "/clear #" *> (ClearGroup <$> displayNameP), "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), + "/member support chats #" *> (ListMemberSupportChats <$> displayNameP), "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), @@ -4120,6 +4187,7 @@ chatCommandP = "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayNameP <*> _strP <*> optional memberRole), "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayNameP <*> (A.space *> strP)), + "/set new member review #" *> (SetGroupFeature (AGFNR SGFNewMemberReview) <$> displayNameP <*> (A.space *> strP)), "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayNameP <*> (A.space *> strP)), "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayNameP <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), @@ -4256,10 +4324,16 @@ chatCommandP = CTLocal -> pure $ ChatName CTLocal "" ct -> ChatName ct <$> displayNameP chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP - chatRefP = ChatRef <$> chatTypeP <*> A.decimal + chatRefP = do + chatTypeP >>= \case + CTGroup -> ChatRef CTGroup <$> A.decimal <*> (Just <$> gcScopeP <|> pure Nothing) + cType -> (\chatId -> ChatRef cType chatId Nothing) <$> A.decimal sendRefP = (A.char '@' $> SRDirect <*> A.decimal) - <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional (" @" *> A.decimal)) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> (Just <$> gcScopeP <|> pure Nothing)) + gcScopeP = + ("(_support:" *> (GCSMemberSupport . Just <$> A.decimal) <* ")") + <|> ("(_support)" $> (GCSMemberSupport Nothing)) msgCountP = A.space *> A.decimal <|> pure 10 ciTTLDecimal = ("default" $> Nothing) <|> (Just <$> A.decimal) ciTTL = diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 0d7de70098..f41d67d37a 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -190,13 +190,13 @@ toggleNtf user m ntfOn = forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) -prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) -prepareGroupMsg db user g@GroupInfo {membership} mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of +prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of (Nothing, Nothing) -> - let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) + let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope in pure (XMsgNew mc', Nothing) (Nothing, Just _) -> - let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) + let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope in pure (XMsgNew mc', Nothing) (Just quotedItemId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- @@ -206,7 +206,7 @@ prepareGroupMsg db user g@GroupInfo {membership} mc mentions quotedItemId_ itemF qmc = quoteContent mc origQmc file (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} - mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live)) + mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope) pure (XMsgNew mc', Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where @@ -453,8 +453,8 @@ deleteDirectCIs user ct items byUser timed = do deleteDirectChatItem db user ct ci pure $ contactDeletion md ct ci Nothing -deleteGroupCIs :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> CM ChatResponse -deleteGroupCIs user gInfo items byUser timed byGroupMember_ deletedTs = do +deleteGroupCIs :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> CM ChatResponse +deleteGroupCIs user gInfo chatScopeInfo items byUser timed byGroupMember_ deletedTs = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items deleteCIFiles user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items) @@ -466,7 +466,7 @@ deleteGroupCIs user gInfo items byUser timed byGroupMember_ deletedTs = do ci' <- case byGroupMember_ of Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs Nothing -> Nothing <$ deleteGroupChatItem db user gInfo ci - pure $ groupDeletion md gInfo ci ci' + pure $ groupDeletion md gInfo chatScopeInfo ci ci' deleteGroupMemberCIs :: MsgDirectionI d => User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> CM () deleteGroupMemberCIs user gInfo member byGroupMember msgDir = do @@ -517,8 +517,8 @@ markDirectCIsDeleted user ct items byUser deletedTs = do ci' <- markDirectChatItemDeleted db user ct ci deletedTs pure $ contactDeletion md ct ci (Just ci') -markGroupCIsDeleted :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Bool -> Maybe GroupMember -> UTCTime -> CM ChatResponse -markGroupCIsDeleted user gInfo items byUser byGroupMember_ deletedTs = do +markGroupCIsDeleted :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Bool -> Maybe GroupMember -> UTCTime -> CM ChatResponse +markGroupCIsDeleted user gInfo chatScopeInfo items byUser byGroupMember_ deletedTs = do let ciFilesInfo = mapMaybe (\(CChatItem _ ChatItem {file}) -> mkCIFileInfo <$> file) items cancelFilesInProgress user ciFilesInfo (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items) @@ -527,7 +527,7 @@ markGroupCIsDeleted user gInfo items byUser byGroupMember_ deletedTs = do where markDeleted db (CChatItem md ci) = do ci' <- markGroupChatItemDeleted db user gInfo ci byGroupMember_ deletedTs - pure $ groupDeletion md gInfo ci (Just ci') + pure $ groupDeletion md gInfo chatScopeInfo ci (Just ci') markGroupMemberCIsDeleted :: User -> GroupInfo -> GroupMember -> GroupMember -> CM () markGroupMemberCIsDeleted user gInfo member byGroupMember = do @@ -547,10 +547,10 @@ markGroupMemberCIsDeleted_ db user gInfo member byGroupMember deletedTs = do markMemberCIsDeleted db user gInfo member byGroupMember deletedTs pure fs -groupDeletion :: MsgDirectionI d => SMsgDirection d -> GroupInfo -> ChatItem 'CTGroup d -> Maybe (ChatItem 'CTGroup d) -> ChatItemDeletion -groupDeletion md g ci ci' = ChatItemDeletion (gItem ci) (gItem <$> ci') +groupDeletion :: MsgDirectionI d => SMsgDirection d -> GroupInfo -> Maybe GroupChatScopeInfo -> ChatItem 'CTGroup d -> Maybe (ChatItem 'CTGroup d) -> ChatItemDeletion +groupDeletion md g chatScopeInfo ci ci' = ChatItemDeletion (gItem ci) (gItem <$> ci') where - gItem = AChatItem SCTGroup md (GroupChat g) + gItem = AChatItem SCTGroup md (GroupChat g chatScopeInfo) contactDeletion :: MsgDirectionI d => SMsgDirection d -> Contact -> ChatItem 'CTDirect d -> Maybe (ChatItem 'CTDirect d) -> ChatItemDeletion contactDeletion md ct ci ci' = ChatItemDeletion (ctItem ci) (ctItem <$> ci') @@ -564,7 +564,7 @@ updateCallItemStatus user ct@Contact {contactId} Call {chatItemId} receivedStatu timed_ <- callTimed ct aciContent updateDirectChatItemView user ct chatItemId aciContent False False timed_ msgId_ forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId) + startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId) updateDirectChatItemView :: User -> Contact -> ChatItemId -> ACIContent -> Bool -> Bool -> Maybe CITimed -> Maybe MessageId -> CM () updateDirectChatItemView user ct chatItemId (ACIContent msgDir ciContent) edited live timed_ msgId_ = do @@ -653,10 +653,10 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI _ -> do chatRef <- withStore $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of - (ChatRef CTDirect contactId, Nothing) -> do + (ChatRef CTDirect contactId _, Nothing) -> do ct <- withStore $ \db -> getContact db vr user contactId acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg - (ChatRef CTGroup groupId, Just memId) -> do + (ChatRef CTGroup groupId _, Just memId) -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId case activeConn of Just conn -> do @@ -859,13 +859,13 @@ acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = Agen acceptGroupJoinRequestAsync :: User -> GroupInfo -> UserContactRequest -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember acceptGroupJoinRequestAsync user - gInfo@GroupInfo {groupProfile, membership, businessChat} + gInfo@GroupInfo {groupProfile, fullGroupPreferences, membership, businessChat} ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} gAccepted gLinkMemRole incognitoProfile = do gVar <- asks random - let initialStatus = acceptanceToStatus gAccepted + let initialStatus = acceptanceToStatus fullGroupPreferences gAccepted (groupMemberId, memberId) <- withStore $ \db -> do liftIO $ deleteContactRequestRec db user ucr createJoiningMember db gVar user gInfo ucr gLinkMemRole initialStatus @@ -950,7 +950,7 @@ acceptBusinessJoinRequestAsync let chatV = vr `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore' $ \db -> createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode - let cd = CDGroupSnd gInfo + let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo pure gInfo @@ -966,13 +966,35 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> NewIncognito p -> p ExistingIncognito lp -> fromLocalProfile lp -introduceToGroup :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToGroup _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = do +introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceToModerators vr user gInfo m = do + modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs = filter memberCurrent modMs + introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) + +introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceToAll vr user gInfo m = do members <- withStore' $ \db -> getGroupMembers db vr user gInfo - void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m - sendIntroductions members - when (groupFeatureAllowed SGFHistory gInfo) sendHistory + let recipients = filter memberCurrent members + introduceMember vr user gInfo m recipients Nothing + +introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceToRemaining vr user gInfo m = do + (members, introducedGMIds) <- + withStore' $ \db -> (,) <$> getGroupMembers db vr user gInfo <*> getIntroducedGroupMemberIds db m + let recipients = filter (introduceMemP introducedGMIds) members + introduceMember vr user gInfo m recipients Nothing + where + introduceMemP introducedGMIds mem = + memberCurrent mem + && groupMemberId' mem `notElem` introducedGMIds + && groupMemberId' mem /= groupMemberId' m + +introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () +introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" +introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo m) msgScope + sendIntroductions introduceToMembers where sendIntroductions members = do intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m @@ -988,7 +1010,7 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac memberIntro reMember = let mInfo = memberInfo reMember mRestrictions = memberRestrictions reMember - in XGrpMemIntro mInfo mRestrictions + in XGrpMemIntro mInfo mRestrictions shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] shuffleIntros intros = do let (admins, others) = partition isAdmin intros @@ -1001,15 +1023,19 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId withStore' $ \db -> updateIntroStatus db introId GMIntroSent - sendHistory = - when (m `supportsVersion` batchSendVersion) $ do - (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) - (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items - let errors = map ChatErrorStore errs <> errs' - unless (null errors) $ toView $ CRChatErrors (Just user) errors - let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_ - forM_ (L.nonEmpty events') $ \events'' -> - sendGroupMemberMessages user conn events'' groupId + +sendHistory :: User -> GroupInfo -> GroupMember -> CM () +sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" +sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = + when (m `supportsVersion` batchSendVersion) $ do + (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) + (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items + let errors = map ChatErrorStore errs <> errs' + unless (null errors) $ toView $ CRChatErrors (Just user) errors + let events' = maybe (concat events) (\x -> concat events <> [x]) descrEvent_ + forM_ (L.nonEmpty events') $ \events'' -> + sendGroupMemberMessages user conn events'' groupId + where descrEvent_ :: Maybe (ChatMsgEvent 'Json) descrEvent_ | m `supportsVersion` groupHistoryIncludeWelcomeVersion = do @@ -1055,7 +1081,7 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac | fileDescrComplete = let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} fInv = xftpFileInvitation fileName fileSize fInvDescr - in Just (fInv, fileDescrText) + in Just (fInv, fileDescrText) | otherwise = Nothing processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] processContentItem sender ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = @@ -1067,7 +1093,8 @@ introduceToGroup vr user gInfo@GroupInfo {groupId, membership} m@GroupMember {ac fInv_ = fst <$> fInvDescr_ (mc', _, mentions') = updatedMentionNames mc formattedText mentions mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions' - (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False + -- TODO [knocking] send history to other scopes too? + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False let senderVRange = memberChatVRange' sender xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of @@ -1132,7 +1159,7 @@ startTimedItemThread user itemRef deleteAt = do atomically $ writeTVar threadTVar (Just tId) deleteTimedItem :: User -> (ChatRef, ChatItemId) -> UTCTime -> CM () -deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do +deleteTimedItem user (ChatRef cType chatId scope, itemId) deleteAt = do ts <- liftIO getCurrentTime liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts lift waitChatStartedAndActivated @@ -1144,7 +1171,8 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do CTGroup -> do (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime - deleteGroupCIs user gInfo [ci] True True Nothing deletedTs >>= toView + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + deleteGroupCIs user gInfo chatScopeInfo [ci] True True Nothing deletedTs >>= toView _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" startUpdatedTimedItemThread :: User -> ChatRef -> ChatItem c d -> ChatItem c d -> CM () @@ -1259,6 +1287,101 @@ parseChatMessage conn s = do errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) {-# INLINE parseChatMessage #-} +getChatScopeInfo :: VersionRangeChat -> User -> GroupChatScope -> CM GroupChatScopeInfo +getChatScopeInfo vr user = \case + GCSMemberSupport Nothing -> pure $ GCSIMemberSupport Nothing + GCSMemberSupport (Just gmId) -> do + supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId + pure $ GCSIMemberSupport (Just supportMem) + +-- TODO [knocking] refactor to GroupChatScope -> "a" function, "a" is some new type? Or possibly split to get scope/get recipients steps +getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> VersionChat -> CM (Maybe GroupChatScopeInfo, [GroupMember]) +getGroupRecipients vr user gInfo@GroupInfo {membership} scope modsCompatVersion = case scope of + Nothing -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + let recipients = filter memberCurrent ms + pure (Nothing, recipients) + Just (GCSMemberSupport Nothing) -> do + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs + when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" + let scopeInfo = GCSIMemberSupport Nothing + pure (Just scopeInfo, rcpModMs') + Just (GCSMemberSupport (Just gmId)) -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId + unless (memberCurrentOrPending supportMem) $ throwChatError $ CECommandError "support member not current or pending" + let scopeInfo = GCSIMemberSupport (Just supportMem) + if memberStatus supportMem == GSMemPendingApproval + then pure (Just scopeInfo, [supportMem]) + else do + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs + pure (Just scopeInfo, [supportMem] <> rcpModMs') + where + compatible GroupMember {activeConn, memberChatVRange} = + maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= modsCompatVersion + +mkLocalGroupChatScope :: GroupInfo -> IO (GroupInfo, Maybe GroupChatScopeInfo) +mkLocalGroupChatScope gInfo@GroupInfo {membership} + | memberPending membership = do + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo + pure (gInfo', Just scopeInfo) + | otherwise = + pure (gInfo, Nothing) + +mkGroupChatScope :: GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGroupChatScope gInfo@GroupInfo {membership} m + | memberPending membership = do + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo + pure (gInfo', m, Just scopeInfo) + | memberPending m = do + (m', scopeInfo) <- mkMemberSupportChatInfo m + pure (gInfo, m', Just scopeInfo) + | otherwise = + pure (gInfo, m, Nothing) + +mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = + liftIO (mkGroupChatScope gInfo m) >>= \case + groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope + (_, _, Nothing) -> case msgScope_ of + Nothing -> pure (gInfo, m, Nothing) + Just (MSMember mId) + | sameMemberId mId membership -> do + (gInfo', scopeInfo) <- liftIO $ mkGroupSupportChatInfo gInfo + pure (gInfo', m, Just scopeInfo) + | otherwise -> do + referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId + -- TODO [knocking] return patched _referredMember too? + (_referredMember', scopeInfo) <- liftIO $ mkMemberSupportChatInfo referredMember + pure (gInfo, m, Just scopeInfo) + +mkGroupSupportChatInfo :: GroupInfo -> IO (GroupInfo, GroupChatScopeInfo) +mkGroupSupportChatInfo gInfo@GroupInfo {modsSupportChat} = + case modsSupportChat of + Nothing -> do + chatTs <- getCurrentTime + let gInfo' = gInfo {modsSupportChat = Just $ GroupSupportChat chatTs True} + scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} + pure (gInfo', scopeInfo) + Just _supportChat -> + let scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} + in pure (gInfo, scopeInfo) + +mkMemberSupportChatInfo :: GroupMember -> IO (GroupMember, GroupChatScopeInfo) +mkMemberSupportChatInfo m@GroupMember {supportChat} = + case supportChat of + Nothing -> do + chatTs <- getCurrentTime + let m' = m {supportChat = Just $ GroupSupportChat chatTs True} + scopeInfo = GCSIMemberSupport {groupMember_ = Just m'} + pure (m', scopeInfo) + Just _supportChat -> + let scopeInfo = GCSIMemberSupport {groupMember_ = Just m} + in pure (m, scopeInfo) + sendFileChunk :: User -> SndFileTransfer -> CM () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do @@ -1611,9 +1734,9 @@ deliverMessagesB msgReqs = do where updatePQ = updateConnPQSndEnabled db connId pqSndEnabled' -sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage -sendGroupMessage user gInfo members chatMsgEvent = do - sendGroupMessages user gInfo members (chatMsgEvent :| []) >>= \case +sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage +sendGroupMessage user gInfo gcScope members chatMsgEvent = do + sendGroupMessages user gInfo gcScope members (chatMsgEvent :| []) >>= \case ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message" @@ -1623,9 +1746,9 @@ sendGroupMessage' user gInfo members chatMsgEvent = ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" -sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages user gInfo members events = do - -- TODO [knocking] when sending to all, send profile update to pending approval members too, then filter for next step? +sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupMessages user gInfo scope members events = do + -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ sendProfileUpdate `catchChatError` (toView . CRChatError (Just user)) sendGroupMessages_ user gInfo members events @@ -1633,6 +1756,7 @@ sendGroupMessages user gInfo members events = do User {profile = p, userMemberProfileUpdatedAt} = user GroupInfo {userMemberProfileSentAt} = gInfo shouldSendProfileUpdate + | isJust scope = False -- why not sending profile updates to scopes? | incognitoMembership gInfo = False | otherwise = case (userMemberProfileSentAt, userMemberProfileUpdatedAt) of @@ -1653,16 +1777,13 @@ data GroupSndResult = GroupSndResult } sendGroupMessages_ :: MsgEncodingI e => User -> GroupInfo -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) -sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do +sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do let idsEvts = L.map (GroupId groupId,) events sndMsgs_ <- lift $ createSndMessages idsEvts - -- TODO [knocking] Possibly we need to pass GroupSndScope through all functions to here to avoid ad-hoc filtering. - recipientMembers <- case members of - [m] | memberStatus m == GSMemPendingApproval -> pure [m] - _ -> liftIO $ shuffleMembers (filter memberCurrent members) + recipientMembers' <- liftIO $ shuffleMembers recipientMembers let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events} (toSendSeparate, toSendBatched, toPending, forwarded, _, dups) = - foldr' addMember ([], [], [], [], S.empty, 0 :: Int) recipientMembers + foldr' (addMember recipientMembers') ([], [], [], [], S.empty, 0 :: Int) recipientMembers' when (dups /= 0) $ logError $ "sendGroupMessages_: " <> tshow dups <> " duplicate members" -- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here -- Deliver to toSend members @@ -1684,7 +1805,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do liftM2 (<>) (shuffle adminMs) (shuffle otherMs) where isAdmin GroupMember {memberRole} = memberRole >= GRAdmin - addMember m acc@(toSendSeparate, toSendBatched, pending, forwarded, !mIds, !dups) = + addMember members m acc@(toSendSeparate, toSendBatched, pending, forwarded, !mIds, !dups) = case memberSendAction gInfo events members m of Just a | mId `S.member` mIds -> (toSendSeparate, toSendBatched, pending, forwarded, mIds, dups + 1) @@ -1861,7 +1982,7 @@ saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothi saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd) saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = do let itemTexts = ciContentTexts content - saveSndChatItems user cd Nothing [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case + saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case [Right ci] -> pure ci _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item" @@ -1880,12 +2001,11 @@ saveSndChatItems :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> - Maybe NotInHistory -> [Either ChatError (NewSndChatItemData c)] -> Maybe CITimed -> Bool -> CM [Either ChatError (ChatItem c 'MDSnd)] -saveSndChatItems user cd notInHistory_ itemsData itemTimed live = do +saveSndChatItems user cd itemsData itemTimed live = do createdAt <- liftIO getCurrentTime when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ withStore' (\db -> updateChatTs db user cd createdAt) @@ -1893,11 +2013,11 @@ saveSndChatItems user cd notInHistory_ itemsData itemTimed live = do where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do - ciId <- createNewSndChatItem db user cd notInHistory_ msg content quotedItem itemForwarded itemTimed live createdAt + ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False createdAt Nothing createdAt Right <$> case cd of - CDGroupSnd g | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions + CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) @@ -1905,18 +2025,18 @@ saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg broker saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = - saveRcvChatItem' user cd Nothing msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty + saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) ciContentNoParse content = (content, (ciContentToText content, Nothing)) -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> Maybe NotInHistory -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv) -saveRcvChatItem' user cd notInHistory_ msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv) +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime withStore' $ \db -> do when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt (mentions' :: Map MemberName CIMention, userMention) <- case cd of - CDGroupRcv g@GroupInfo {membership} _ -> do + CDGroupRcv g@GroupInfo {membership} _scope _m -> do mentions' <- getRcvCIMentions db user g ft_ mentions let userReply = case cmToQuotedMsg chatMsgEvent of Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership @@ -1924,11 +2044,11 @@ saveRcvChatItem' user cd notInHistory_ msg@RcvMessage {chatMsgEvent, forwardedBy userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' in pure (mentions', userMention') CDDirectRcv _ -> pure (M.empty, False) - (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd notInHistory_ msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt + (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt case cd of - CDGroupRcv g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' + CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci -- TODO [mentions] optimize by avoiding unnecessary parsing @@ -2170,7 +2290,7 @@ createLocalChatItems user cd itemsData createdAt = do where createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) createItem db (content, ciFile, itemForwarded, ts) = do - ciId <- createNewChatItem_ db user cd Nothing Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt + ciId <- createNewChatItem_ db user cd Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False createdAt Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure $ mkChatItem_ cd ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False createdAt Nothing createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f9fcecc5a1..2d747298ee 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -162,9 +162,9 @@ processAgentMsgSndFile _corrId aFileId msg = do throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId where withEntityLock_ :: Maybe ChatRef -> CM a -> CM a - withEntityLock_ cRef_ = case cRef_ of - Just (ChatRef CTDirect contactId) -> withContactLock "processAgentMsgSndFile" contactId - Just (ChatRef CTGroup groupId) -> withGroupLock "processAgentMsgSndFile" groupId + withEntityLock_ = \case + Just (ChatRef CTDirect contactId _) -> withContactLock "processAgentMsgSndFile" contactId + Just (ChatRef CTGroup groupId _scope) -> withGroupLock "processAgentMsgSndFile" groupId _ -> id process :: User -> FileTransferId -> CM () process user fileId = do @@ -212,7 +212,7 @@ processAgentMsgSndFile _corrId aFileId msg = do Left e -> toView $ CRChatError (Just user) e Nothing -> toView $ CRChatError (Just user) $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) - (_, _, SMDSnd, GroupChat g@GroupInfo {groupId}) -> do + (_, _, SMDSnd, GroupChat g@GroupInfo {groupId} _scope) -> do ms <- withStore' $ \db -> getGroupMembers db vr user g let rfdsMemberFTs = zipWith (\rfd (conn, sft) -> (conn, sft, fileDescrText rfd)) rfds (memberFTs ms) extraRFDs = drop (length rfdsMemberFTs) rfds @@ -304,9 +304,9 @@ processAgentMsgRcvFile _corrId aFileId msg = do throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId where withEntityLock_ :: Maybe ChatRef -> CM a -> CM a - withEntityLock_ cRef_ = case cRef_ of - Just (ChatRef CTDirect contactId) -> withContactLock "processAgentMsgRcvFile" contactId - Just (ChatRef CTGroup groupId) -> withGroupLock "processAgentMsgRcvFile" groupId + withEntityLock_ = \case + Just (ChatRef CTDirect contactId _) -> withContactLock "processAgentMsgRcvFile" contactId + Just (ChatRef CTGroup groupId _scope) -> withGroupLock "processAgentMsgRcvFile" groupId _ -> id process :: User -> FileTransferId -> CM () process user fileId = do @@ -486,7 +486,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent _ ttl live -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live + XMsgUpdate sharedMsgId mContent _ ttl live _msgScope -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile @@ -716,7 +716,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = } (_msg, _) <- sendDirectContactMessage user ct $ XGrpInv groupInv -- we could link chat item with sent group invitation message (_msg) - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + createInternalChatItem user (CDGroupRcv gInfo Nothing m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing -- TODO REMOVE LEGACY ^^^ _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" @@ -762,54 +762,68 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XOk -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON _pqEnc -> unless (memberStatus m == GSMemRejected) $ do - status' <- case memberStatus m of - GSMemPendingApproval -> pure GSMemPendingApproval - _ -> do - withStore' $ \db -> do - updateGroupMemberStatus db userId m GSMemConnected - unless (memberActive membership) $ - updateGroupMemberStatus db userId membership GSMemConnected - -- possible improvement: check for each pending message, requires keeping track of connection state - unless (connDisabled conn) $ sendPendingGroupMessages user m conn - pure GSMemConnected + CON _pqEnc -> unless (memberStatus m == GSMemRejected || memberStatus membership == GSMemRejected) $ do + -- TODO [knocking] send pending messages after accepting? + -- possible improvement: check for each pending message, requires keeping track of connection state + unless (connDisabled conn) $ sendPendingGroupMessages user m conn withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do - toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = status'}} m {memberStatus = status'} - let cd = CDGroupRcv gInfo m + (mStatus, membershipStatus) <- withStore' $ \db -> do + updateGroupMemberStatus db userId m GSMemConnected + membershipStatus <- + if not (memberPending membership) + then updateGroupMemberStatus db userId membership GSMemConnected $> GSMemConnected + else pure $ memberStatus membership + pure (GSMemConnected, membershipStatus) + toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = membershipStatus}} m {memberStatus = mStatus} + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + let cd = CDGroupRcv gInfo' scopeInfo m' createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing - createGroupFeatureItems user cd CIRcvGroupFeature gInfo - let GroupInfo {groupProfile = GroupProfile {description}} = gInfo - memberConnectedChatItem gInfo m - unless expectHistory $ forM_ description $ groupDescriptionChatItem gInfo m - where - expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion + createGroupFeatureItems user cd CIRcvGroupFeature gInfo' + memberConnectedChatItem gInfo' scopeInfo m' + unless (memberPending membership) $ maybeCreateGroupDescrLocal gInfo' m' GCInviteeMember -> do - memberConnectedChatItem gInfo m - toView $ CRJoinedGroupMember user gInfo m {memberStatus = status'} + mStatus <- + if not (memberPending m) + then withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected + else pure $ memberStatus m + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + memberConnectedChatItem gInfo' scopeInfo m' + toView $ CRJoinedGroupMember user gInfo' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn - when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem + when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo' when (connChatVersion < batchSend2Version) sendGroupAutoReply - unless (status' == GSMemPendingApproval) $ introduceToGroup vr user gInfo m + case mStatus of + GSMemPendingApproval -> pure () + -- edge case: reviews were turned off mid connection; + -- options: proceed to review as declared, or introduce to group and send XGrpLinkAcpt; + -- choosing first option for simplicity, same edge case for approval is also not considered + GSMemPendingReview -> introduceToModerators vr user gInfo' m' + _ -> do + introduceToAll vr user gInfo' m' + when (groupFeatureAllowed SGFHistory gInfo') $ sendHistory user gInfo' m' where - sendXGrpLinkMem = do - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + sendXGrpLinkMem gInfo' = do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo' profileToSend = profileToSendOnAccept user profileMode True void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do + unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected let memCategory = memberCategory m withStore' (\db -> getViaGroupContact db vr user m) >>= \case Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memCategory == GCPreMember) $ probeMatchingMemberContact m connectedIncognito + when (memCategory == GCPreMember && not (memberPending membership)) $ + probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn} -> forM_ activeConn $ \Connection {connStatus} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memCategory == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True + when (memCategory == GCPreMember && not (memberPending membership)) $ + probeMatchingContactsAndMembers ct connectedIncognito True sendXGrpMemCon memCategory where GroupMember {memberId} = m @@ -828,10 +842,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MSG msgMeta _msgFlags msgBody -> do tags <- newTVarIO [] withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () + -- possible improvement is to choose scope based on event (some events specify scope) + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent tags eInfo chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e + processEvent gInfo' m' tags eInfo chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e @@ -841,43 +857,43 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where aChatMsgs = parseChatMessages msgBody brokerTs = metaBrokerTs msgMeta - processEvent :: TVar [Text] -> Text -> MsgEncodingI e => ChatMessage e -> CM () - processEvent tags eInfo chatMsg@ChatMessage {chatMsgEvent} = do + processEvent :: GroupInfo -> GroupMember -> TVar [Text] -> Text -> MsgEncodingI e => ChatMessage e -> CM () + processEvent gInfo' m' tags eInfo chatMsg@ChatMessage {chatMsgEvent} = do let tag = toCMEventTag chatMsgEvent atomically $ modifyTVar' tags (tshow tag :) logInfo $ "group msg=" <> tshow tag <> " " <> eInfo - (m', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m conn msgMeta msgBody chatMsg + (m'', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta msgBody chatMsg case event of - XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs False - XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent mentions msg brokerTs ttl live - XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg brokerTs - XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m' sharedMsgId memberId reaction add msg brokerTs + XMsgNew mc -> memberCanSend m'' $ newGroupContentMessage gInfo' m'' mc msg brokerTs False + XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m'' $ groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live + XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo' m'' sharedMsgId memberId msg brokerTs + XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId reaction add msg brokerTs -- TODO discontinue XFile - XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg brokerTs - XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId - XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName - XInfo p -> xInfoMember gInfo m' p brokerTs - XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p - XGrpLinkAcpt role -> xGrpLinkAcpt gInfo m' role - XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg brokerTs - XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo m' memInfo memRestrictions_ - XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv - XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo m' memInfo introInv - XGrpMemRole memId memRole -> xGrpMemRole gInfo m' memId memRole msg brokerTs - XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo m' memId memRestrictions msg brokerTs - XGrpMemCon memId -> xGrpMemCon gInfo m' memId - XGrpMemDel memId withMessages -> xGrpMemDel gInfo m' memId withMessages msg brokerTs - XGrpLeave -> xGrpLeave gInfo m' msg brokerTs - XGrpDel -> xGrpDel gInfo m' msg brokerTs - XGrpInfo p' -> xGrpInfo gInfo m' p' msg brokerTs - XGrpPrefs ps' -> xGrpPrefs gInfo m' ps' - XGrpDirectInv connReq mContent_ -> memberCanSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg brokerTs - XGrpMsgForward memberId msg' msgTs -> xGrpMsgForward gInfo m' memberId msg' msgTs - XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash - XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe - BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta + XFile fInv -> processGroupFileInvitation' gInfo' m'' fInv msg brokerTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' m'' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName + XInfo p -> xInfoMember gInfo' m'' p brokerTs + XGrpLinkMem p -> xGrpLinkMem gInfo' m'' conn' p + XGrpLinkAcpt role memberId_ -> xGrpLinkAcpt gInfo' m'' role memberId_ + XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo' m'' memInfo memRestrictions_ + XGrpMemInv memId introInv -> xGrpMemInv gInfo' m'' memId introInv + XGrpMemFwd memInfo introInv -> xGrpMemFwd gInfo' m'' memInfo introInv + XGrpMemRole memId memRole -> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs + XGrpMemCon memId -> xGrpMemCon gInfo' m'' memId + XGrpMemDel memId withMessages -> xGrpMemDel gInfo' m'' memId withMessages msg brokerTs + XGrpLeave -> xGrpLeave gInfo' m'' msg brokerTs + XGrpDel -> xGrpDel gInfo' m'' msg brokerTs + XGrpInfo p' -> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpPrefs ps' -> xGrpPrefs gInfo' m'' ps' + XGrpDirectInv connReq mContent_ -> memberCanSend m'' $ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs + XGrpMsgForward memberId msg' msgTs -> xGrpMsgForward gInfo' m'' memberId msg' msgTs + XInfoProbe probe -> xInfoProbe (COMGroupMember m'') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m'') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m'') probe + BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> tshow event checkSendRcpt :: [AChatMessage] -> CM Bool checkSendRcpt aMsgs = do @@ -892,6 +908,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = hasDeliveryReceipt (toCMEventTag chatMsgEvent) forwardMsgs :: [AChatMessage] -> CM () forwardMsgs aMsgs = do + -- TODO [knocking] forward to/from GSMemPendingReview members let GroupMember {memberRole = membershipMemRole} = membership when (membershipMemRole >= GRAdmin && not (blockedByAdmin m)) $ do let forwardedMsgs = mapMaybe (\(ACMsg _ chatMsg) -> forwardedGroupMsg chatMsg) aMsgs @@ -907,7 +924,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let GroupMember {memberId} = m ms = forwardedToGroupMembers (introducedMembers <> invitedMembers) forwardedMsgs' events = L.map (\cm -> XGrpMsgForward memberId cm brokerTs) forwardedMsgs' - unless (null ms) $ void $ sendGroupMessages user gInfo ms events + unless (null ms) $ void $ sendGroupMessages_ user gInfo ms events RCVD msgMeta msgRcpt -> withAckMessage' "group rcvd" agentConnId msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -919,36 +936,38 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when continued $ sendPendingGroupMessages user m conn SWITCH qd phase cStats -> do toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m when (phase == SPStarted || phase == SPCompleted) $ case qd of - QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing - QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing - RSYNC rss cryptoErr_ cStats -> + QDRcv -> createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m') Nothing + QDSnd -> createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvConnEvent $ RCESwitchQueue phase) Nothing + RSYNC rss cryptoErr_ cStats -> do + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m case (rss, connectionCode, cryptoErr_) of - (RSRequired, _, Just cryptoErr) -> processErr cryptoErr - (RSAllowed, _, Just cryptoErr) -> processErr cryptoErr + (RSRequired, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr + (RSAllowed, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr (RSAgreed, Just _, _) -> do withStore' $ \db -> setConnectionVerified db user connId Nothing - let m' = m {activeConn = Just (conn {connectionCode = Nothing} :: Connection)} :: GroupMember - ratchetSyncEventItem m' - toView $ CRGroupMemberVerificationReset user gInfo m' - createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent RCEVerificationCodeReset) Nothing - _ -> ratchetSyncEventItem m + let m'' = m' {activeConn = Just (conn {connectionCode = Nothing} :: Connection)} :: GroupMember + ratchetSyncEventItem gInfo' scopeInfo m'' + toView $ CRGroupMemberVerificationReset user gInfo' m'' + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') (CIRcvConnEvent RCEVerificationCodeReset) Nothing + _ -> ratchetSyncEventItem gInfo' scopeInfo m' where - processErr cryptoErr = do + processErr gInfo' scopeInfo m' cryptoErr = do let e@(mde, n) = agentMsgDecryptError cryptoErr ci_ <- withStore $ \db -> - getGroupMemberChatItemLast db user groupId (groupMemberId' m) + getGroupMemberChatItemLast db user groupId (groupMemberId' m') >>= liftIO . mapM (\(ci, content') -> updateGroupChatItem db user groupId ci content' False False Nothing) . mdeUpdatedCI e case ci_ of - Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) + Just ci -> toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo' scopeInfo) ci) _ -> do - toView $ CRGroupMemberRatchetSync user gInfo m (RatchetSyncProgress rss cStats) - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvDecryptionError mde n) Nothing - ratchetSyncEventItem m' = do - toView $ CRGroupMemberRatchetSync user gInfo m' (RatchetSyncProgress rss cStats) - createInternalChatItem user (CDGroupRcv gInfo m') (CIRcvConnEvent $ RCERatchetSync rss) Nothing + toView $ CRGroupMemberRatchetSync user gInfo' m' (RatchetSyncProgress rss cStats) + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvDecryptionError mde n) Nothing + ratchetSyncEventItem gInfo' scopeInfo m' = do + toView $ CRGroupMemberRatchetSync user gInfo' m' (RatchetSyncProgress rss cStats) + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvConnEvent $ RCERatchetSync rss) Nothing OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () @@ -998,9 +1017,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing send mc = do msg <- sendGroupMessage' user gInfo [m] (XMsgNew $ MCSimple (extMsgContent mc Nothing)) - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) + ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndMsgContent mc) withStore' $ \db -> createGroupSndStatus db (chatItemId' ci) (groupMemberId' m) GSSNew - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] + toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing) ci] agentMsgDecryptError :: AgentCryptoError -> (MsgDecryptError, Word32) agentMsgDecryptError = \case @@ -1056,7 +1075,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = SMP _ SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> do liftIO (lookupChatRefByFileId db user fileId) >>= \case - Just (ChatRef CTDirect _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled + Just (ChatRef CTDirect _ _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled _ -> pure () lookupChatItemByFileId db vr user fileId toView $ CRSndFileRcvCancelled user ci ft @@ -1215,8 +1234,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo mem <- acceptGroupJoinRequestAsync user gInfo cReq acceptance useRole profileMode - createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + (gInfo', mem', scopeInfo) <- liftIO $ mkGroupChatScope gInfo mem + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo' mem' Left rjctReason | v < groupJoinRejectVersion -> messageWarning $ "processUserContactRequest (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" @@ -1225,10 +1245,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason _ -> toView $ CRReceivedContactRequest user cReq - -- TODO [knocking] review memberCanSend :: GroupMember -> CM () -> CM () - memberCanSend GroupMember {memberRole, memberStatus} a - | memberRole > GRObserver || memberStatus == GSMemPendingApproval = a + memberCanSend m@GroupMember {memberRole} a + | memberRole > GRObserver || memberPending m = a | otherwise = messageError "member is not allowed to send messages" processConnMERR :: ConnectionEntity -> Connection -> AgentErrorType -> CM () @@ -1353,20 +1372,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) throwChatError $ CEFileRcvChunk err - memberConnectedChatItem :: GroupInfo -> GroupMember -> CM () - memberConnectedChatItem gInfo m = + memberConnectedChatItem :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> CM () + memberConnectedChatItem gInfo scopeInfo m = -- ts should be broker ts but we don't have it for CON - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing - - groupDescriptionChatItem :: GroupInfo -> GroupMember -> Text -> CM () - groupDescriptionChatItem gInfo m descr = - createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing + createInternalChatItem user (CDGroupRcv gInfo scopeInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> CM () notifyMemberConnected gInfo m ct_ = do - memberConnectedChatItem gInfo m + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + memberConnectedChatItem gInfo' scopeInfo m' lift $ mapM_ (`setContactNetworkStatus` NSConnected) ct_ - toView $ CRConnectedToGroupMember user gInfo m ct_ + toView $ CRConnectedToGroupMember user gInfo' m' ct_ probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> CM () probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do @@ -1430,7 +1446,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do - let ExtMsgContent content _ fInv_ _ _ = mcExtMsgContent mc + let ExtMsgContent content _ fInv_ _ _ _ = mcExtMsgContent mc -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete -- case content of -- MCText "hello 111" -> @@ -1441,7 +1457,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do void $ newChatItem (ciContentNoParse $ CIRcvChatFeatureRejected CFVoice) Nothing Nothing False else do - let ExtMsgContent _ _ _ itemTTL live_ = mcExtMsgContent mc + let ExtMsgContent _ _ _ itemTTL live_ _ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct @@ -1450,7 +1466,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where brokerTs = metaBrokerTs msgMeta newChatItem content ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}] @@ -1468,7 +1484,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM () groupMessageFileDescription g@GroupInfo {groupId} m sharedMsgId fileDescr = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - processFDMessage (CDGroupRcv g m) sharedMsgId fileId fileDescr + -- here scope we pass only affects how chat item is searched in getAChatItemBySharedMsgId, and it ignores scope + processFDMessage (CDGroupRcv g Nothing m) sharedMsgId fileId fileDescr processFDMessage :: ChatTypeQuotable c => ChatDirection c 'MDRcv -> SharedMsgId -> FileTransferId -> FileDescr -> CM () processFDMessage cd sharedMsgId fileId fileDescr = do @@ -1517,7 +1534,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl ts = ciContentTexts content - ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content True live Nothing Nothing @@ -1541,7 +1558,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let edited = itemLive /= Just True updateDirectChatItem' db user contactId ci {reactions} content edited live Nothing $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' + startUpdatedTimedItemThread user (ChatRef CTDirect contactId Nothing) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> messageError "x.msg.update: contact attempted invalid message update" @@ -1598,12 +1615,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateChatItemReaction = do cr_ <- withStore $ \db -> do CChatItem md ci <- getGroupMemberCIBySharedMsgId db user g itemMemberId sharedMsgId + scopeInfo <- getGroupChatScopeInfoForItem db vr user g (chatItemId' ci) if ciReactionAllowed ci then liftIO $ do setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs reactions <- getGroupCIReactions db g itemMemberId sharedMsgId let ci' = CChatItem md ci {reactions} - r = ACIReaction SCTGroup SMDRcv (GroupChat g) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction + r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction pure $ Just $ CRChatItemReaction user add r else pure Nothing mapM_ toView cr_ @@ -1632,33 +1650,38 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = rejected f = newChatItem (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False timed' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo itemTTL live' = fromMaybe False live_ - ExtMsgContent content mentions fInv_ itemTTL live_ = mcExtMsgContent mc + ExtMsgContent content mentions fInv_ itemTTL live_ msgScope_ = mcExtMsgContent mc ts@(_, ft_) = msgContentTexts content - saveRcvCI = saveRcvChatItem' user (CDGroupRcv gInfo m) (memberNotInHistory m) msg sharedMsgId_ brokerTs + saveRcvCI gInfo' scopeInfo m' = saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg sharedMsgId_ brokerTs createBlockedByAdmin | groupFeatureAllowed SGFFullDelete gInfo = do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ -- ignores member role when blocked by admin - ci <- saveRcvCI (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty - ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo ci brokerTs - groupMsgToView gInfo ci' + ci <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty + ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo' ci brokerTs + groupMsgToView gInfo' scopeInfo ci' | otherwise = do file_ <- processFileInv ci <- createNonLive file_ ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci - groupMsgToView gInfo ci' + (gInfo', _m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + groupMsgToView gInfo' scopeInfo ci' applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do - ci <- saveRcvCI (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty - ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt - groupMsgToView gInfo ci' + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + ci <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty + ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo' ci moderator moderatedAt + groupMsgToView gInfo' scopeInfo ci' | otherwise = do + (gInfo', _m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ file_ <- processFileInv ci <- createNonLive file_ - toView =<< markGroupCIsDeleted user gInfo [CChatItem SMDRcv ci] False (Just moderator) moderatedAt - createNonLive file_ = - saveRcvCI (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions + toView =<< markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] False (Just moderator) moderatedAt + createNonLive file_ = do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + saveRcvCI gInfo' scopeInfo m' (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions createContentItem = do file_ <- processFileInv newChatItem (CIRcvMsgContent content, ts) (snd <$> file_) timed' live' @@ -1667,13 +1690,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m newChatItem ciContent ciFile_ timed_ live = do let mentions' = if showMessages (memberSettings m) then mentions else [] - ci <- saveRcvCI ciContent ciFile_ timed_ live mentions' - ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo ci' {reactions} + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + ci <- saveRcvCI gInfo' scopeInfo m' ciContent ciFile_ timed_ live mentions' + ci' <- blockedMember m' ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo' ci + reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ + groupMsgToView gInfo' scopeInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msg@RcvMessage {msgId} brokerTs ttl_ live_ + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ | prohibitedSimplexLinks gInfo m ft_ = messageWarning $ "x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks | otherwise = do @@ -1683,18 +1707,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ mentions' = if showMessages (memberSettings m) then mentions else [] - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) (memberNotInHistory m) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + ci <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc ci' <- updateGroupChatItem db user groupId ci content True live Nothing - blockedMember m ci' $ markGroupChatItemBlocked db user gInfo ci' - toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') + blockedMember m' ci' $ markGroupChatItemBlocked db user gInfo' ci' + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo' scopeInfo) ci') where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user gInfo groupMemberId sharedMsgId + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) case cci of CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> if sameMemberId memberId m' @@ -1710,17 +1736,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ciMentions <- getRcvCIMentions db user gInfo ft_ mentions ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId updateGroupCIMentions db gInfo ci' ciMentions - toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') + startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' + else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) else messageError "x.msg.update: group member attempted to update a message of another member" _ -> messageError "x.msg.update: group member attempted invalid message update" - memberNotInHistory :: GroupMember -> Maybe NotInHistory - memberNotInHistory = \case - GroupMember {memberStatus = GSMemPendingApproval} -> Just NotInHistory - _ -> Nothing - groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> UTCTime -> CM () groupMessageDelete gInfo@GroupInfo {membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} brokerTs = do let msgMemberId = fromMaybe memberId sndMemberId_ @@ -1758,9 +1779,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse - delete cci byGroupMember - | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs - | otherwise = markGroupCIsDeleted user gInfo [cci] False byGroupMember brokerTs + delete cci byGroupMember = do + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) + if groupFeatureMemberAllowed SGFFullDelete m gInfo + then deleteGroupCIs user gInfo scopeInfo [cci] False False byGroupMember brokerTs + else markGroupCIsDeleted user gInfo scopeInfo [cci] False byGroupMember brokerTs archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs @@ -1776,7 +1799,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" - ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] where brokerTs = metaBrokerTs msgMeta @@ -1790,9 +1813,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) Nothing msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + ci <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - groupMsgToView gInfo ci' + groupMsgToView gInfo Nothing ci' blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) blockedMember m ci blockedCI @@ -1943,9 +1966,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" - groupMsgToView :: forall d. MsgDirectionI d => GroupInfo -> ChatItem 'CTGroup d -> CM () - groupMsgToView gInfo ci = - toView $ CRNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo) ci] + groupMsgToView :: forall d. MsgDirectionI d => GroupInfo -> Maybe GroupChatScopeInfo -> ChatItem 'CTGroup d -> CM () + groupMsgToView gInfo chatScopeInfo ci = do + toView $ CRNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo chatScopeInfo) ci] processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> CM () processGroupInvitation ct inv msg msgMeta = do @@ -2064,15 +2087,50 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatchingMemberContact m' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" - xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> CM () - xGrpLinkAcpt gInfo@GroupInfo {membership} m role = do - membership' <- withStore' $ \db -> do - updateGroupMemberStatus db userId m GSMemConnected - updateGroupMemberAccepted db user membership role - let m' = m {memberStatus = GSMemConnected} - toView $ CRUserJoinedGroup user gInfo {membership = membership'} m' - let connectedIncognito = memberIncognito membership - probeMatchingMemberContact m' connectedIncognito + xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> Maybe MemberId -> CM () + xGrpLinkAcpt gInfo@GroupInfo {groupId, membership} m role memberId_ = case memberId_ of + Nothing -> processUserAccepted -- backwards compatibility + Just memberId + | sameMemberId memberId membership -> processUserAccepted + | otherwise -> + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case + Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" + Right referencedMember -> do + referencedMember' <- withFastStore' $ \db -> updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role + when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' + toView $ CRMemberAcceptedByOther user gInfo m referencedMember' + where + newMemberStatus refMem = case memberConn refMem of + Just c | connReady c -> GSMemConnected + _ -> GSMemAnnounced + where + processUserAccepted = do + membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role + toView $ CRUserJoinedGroup user gInfo {membership = membership'} m + let cd = CDGroupRcv gInfo Nothing m + createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing + createGroupFeatureItems user cd CIRcvGroupFeature gInfo + maybeCreateGroupDescrLocal gInfo m + -- Here we can safely probe all known members, because host sends XGrpLinkAcpt before remaining introductions + let connectedIncognito = memberIncognito membership + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + forM_ (filter memberActive members) $ \mem -> + probeMatchingMemberContact mem connectedIncognito + introduceToRemainingMembers acceptedMember = do + case memberConn acceptedMember of + Just mConn -> do + let msg = XGrpLinkAcpt role (Just $ memberId' acceptedMember) + void $ sendDirectMemberMessage mConn msg groupId + introduceToRemaining vr user gInfo acceptedMember + when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember + Nothing -> messageError "x.grp.link.acpt error: no active accepted member connection" + + maybeCreateGroupDescrLocal :: GroupInfo -> GroupMember -> CM () + maybeCreateGroupDescrLocal gInfo@GroupInfo {groupProfile = GroupProfile {description}} m = + unless expectHistory $ forM_ description $ \descr -> + createInternalChatItem user (CDGroupRcv gInfo Nothing m) (CIRcvMsgContent $ MCText descr) Nothing + where + expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_ @@ -2113,8 +2171,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = BCCustomer -> customerId == memberId createProfileUpdatedItem m' = when createItems $ do + (gInfo', m'', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m' let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p' - createInternalChatItem user (CDGroupRcv gInfo m') ciContent itemTs_ + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') ciContent itemTs_ createFeatureEnabledItems :: Contact -> CM () createFeatureEnabledItems ct@Contact {mergedPreferences} = @@ -2221,7 +2280,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do let content = ciContentNoParse $ CIRcvChatFeatureRejected f - ci <- saveRcvChatItem' user (CDDirectRcv ct) Nothing msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] -- to party initiating call @@ -2298,7 +2357,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = timed_ <- callTimed ct aciContent updateDirectChatItemView user ct chatItemId aciContent False False timed_ $ Just msgId forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect ctId', chatItemId) + startProximateTimedItemThread user (ChatRef CTDirect ctId' Nothing, chatItemId) msgCallStateError :: Text -> Call -> CM () msgCallStateError eventName Call {callState} = @@ -2387,25 +2446,34 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO show/log error, other events in SMP confirmation _ -> pure (conn', False) - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> CM () - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msg brokerTs = do + xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM () + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msgScope_ msg brokerTs = do checkHostRole m memRole unless (sameMemberId memId $ membership gInfo) $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo + updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus toView $ CRUnknownMemberAnnounced user gInfo m unknownMember updatedMember memberAnnouncedToView updatedMember Right _ -> messageError "x.grp.mem.new error: member already exists" Left _ -> do - newMember <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + newMember <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus memberAnnouncedToView newMember where + initialStatus = case msgScope_ of + Just (MSMember _) -> GSMemPendingReview + _ -> GSMemAnnounced memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do + (announcedMember', scopeInfo) <- liftIO $ getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event) - groupMsgToView gInfo ci - toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent event) + groupMsgToView gInfo scopeInfo ci + toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember' + getMemNewChatScope announcedMember = case msgScope_ of + Nothing -> pure (announcedMember, Nothing) + Just (MSMember _) -> do + (announcedMember', scopeInfo) <- mkMemberSupportChatInfo announcedMember + pure (announcedMember', Just scopeInfo) xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> CM () xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do @@ -2461,7 +2529,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- For now, this branch compensates for the lack of delayed message delivery. Left _ -> withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced Right m' -> pure m' - withStore' $ \db -> saveMemberInvitation db toMember introInv + -- TODO [knocking] separate pending statuses from GroupMemberStatus? + -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? + -- TODO keep as is? (GSMemIntroInvited has no purpose) + let newMemberStatus = if memberPending toMember then memberStatus toMember else GSMemIntroInvited + withStore' $ \db -> saveMemberInvitation db toMember introInv newMemberStatus subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito let membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership @@ -2489,9 +2561,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo ci - toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} + (gInfo'', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo' m + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView gInfo'' scopeInfo ci + toView CRMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -2517,9 +2590,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = bm' <- setMemberBlocked bm toggleNtf user bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs ciContent - groupMsgToView gInfo ci - toView CRMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked} + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent + groupMsgToView gInfo' scopeInfo ci + toView CRMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId bm' <- setMemberBlocked bm @@ -2601,8 +2675,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo ci + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView gInfo' scopeInfo ci deleteMessages :: MsgDirectionI d => GroupMember -> SMsgDirection d -> CM () deleteMessages delMem msgDir | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupMemberCIs user gInfo delMem m msgDir @@ -2613,9 +2688,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberConnection user m -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEMemberLeft) - groupMsgToView gInfo ci - toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + groupMsgToView gInfo' scopeInfo ci + toView $ CRLeftMember user gInfo' m' {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do @@ -2626,9 +2702,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure members -- member records are not deleted to keep history deleteMembersConnections user ms - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) - groupMsgToView gInfo ci - toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m + (gInfo'', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + groupMsgToView gInfo'' scopeInfo ci + toView $ CRGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM () xGrpInfo g@GroupInfo {groupProfile = p, businessChat} m@GroupMember {memberRole} p' msg brokerTs @@ -2636,12 +2713,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = case businessChat of Nothing -> unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' - toView $ CRGroupUpdated user g g' (Just m) - let cd = CDGroupRcv g' m + (g'', m', scopeInfo) <- liftIO $ mkGroupChatScope g' m + toView $ CRGroupUpdated user g g'' (Just m') + let cd = CDGroupRcv g'' scopeInfo m' unless (sameGroupProfileInfo p p') $ do ci <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') - groupMsgToView g' ci - createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' + groupMsgToView g'' scopeInfo ci + createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> CM () @@ -2654,8 +2732,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (groupPreferences p == Just ps') $ do g' <- withStore' $ \db -> updateGroupPreferences db user g ps' toView $ CRGroupUpdated user g g' (Just m) - let cd = CDGroupRcv g' m - createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' + (g'', m', scopeInfo) <- liftIO $ mkGroupChatScope g' m + let cd = CDGroupRcv g'' scopeInfo m' + createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> CM () xGrpDirectInv g m mConn connReq mContent_ msg brokerTs = do @@ -2695,8 +2774,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do - createInternalChatItem user (CDGroupRcv g m') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing - toView $ CRNewMemberContactReceivedInv user mCt' g m' + (g', m'', scopeInfo) <- liftIO $ mkGroupChatScope g m' + createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing + toView $ CRNewMemberContactReceivedInv user mCt' g' m'' forM_ mContent_ $ \mc -> do ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) toView $ CRNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci] @@ -2725,12 +2805,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case event of XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs True XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent mentions rcvMsg msgTs ttl live + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs XMsgReact sharedMsgId (Just memId) reaction add -> groupMsgReaction gInfo author sharedMsgId memId reaction add rcvMsg msgTs XFileCancel sharedMsgId -> xFileCancelGroup gInfo author sharedMsgId XInfo p -> xInfoMember gInfo author p msgTs - XGrpMemNew memInfo -> xGrpMemNew gInfo author memInfo rcvMsg msgTs + XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs XGrpMemRole memId memRole -> xGrpMemRole gInfo author memId memRole rcvMsg msgTs XGrpMemDel memId withMessages -> xGrpMemDel gInfo author memId withMessages rcvMsg msgTs XGrpLeave -> xGrpLeave gInfo author rcvMsg msgTs @@ -2753,10 +2833,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () + (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus - updateGroupItemsStatus gInfo m conn agentMsgId (GSSRcvd msgRcptStatus) Nothing + updateGroupItemsStatus gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing -- Searches chat items for many agent message IDs and updates their status updateDirectItemsStatusMsgs :: Contact -> Connection -> [AgentMsgId] -> CIStatus 'MDSnd -> CM () @@ -2801,10 +2882,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupItemsStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus viaProxy_ = do items <- withStore' (\db -> getGroupChatItemsByAgentMsgId db user groupId connId msgId) cis <- catMaybes <$> withStore (\db -> mapM (updateItem db) items) - let acis = map gItem cis + -- 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 : _) -> withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (chatItemId' ci) + _ -> pure Nothing + let acis = map (gItem scopeInfo) cis unless (null acis) $ toView $ CRChatItemsStatusesUpdated user acis where - gItem = AChatItem SCTGroup SMDSnd (GroupChat gInfo) + gItem scopeInfo ci = AChatItem SCTGroup SMDSnd (GroupChat gInfo scopeInfo) ci updateItem :: DB.Connection -> CChatItem 'CTGroup -> ExceptT StoreError IO (Maybe (ChatItem 'CTGroup 'MDSnd)) updateItem db = \case (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ SSPComplete}}) -> pure Nothing diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 79d416dee5..00dd999139 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -61,6 +61,25 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord) +data GroupChatScope + = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support + deriving (Eq, Show, Ord) + +data GroupChatScopeTag + = GCSTMemberSupport_ + deriving (Eq, Show) + +instance FromField GroupChatScopeTag where fromField = fromTextField_ textDecode + +instance ToField GroupChatScopeTag where toField = toField . textEncode + +instance TextEncoding GroupChatScopeTag where + textDecode = \case + "member_support" -> Just GCSTMemberSupport_ + _ -> Nothing + textEncode = \case + GCSTMemberSupport_ -> "member_support" + data ChatName = ChatName {chatType :: ChatType, chatName :: Text} deriving (Show) @@ -75,44 +94,52 @@ chatTypeStr = \case chatNameStr :: ChatName -> String chatNameStr (ChatName cType name) = T.unpack $ chatTypeStr cType <> if T.any isSpace name then "'" <> name <> "'" else name -data ChatRef = ChatRef ChatType Int64 +data ChatRef = ChatRef ChatType Int64 (Maybe GroupChatScope) deriving (Eq, Show, Ord) data ChatInfo (c :: ChatType) where DirectChat :: Contact -> ChatInfo 'CTDirect - GroupChat :: GroupInfo -> ChatInfo 'CTGroup + GroupChat :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatInfo 'CTGroup LocalChat :: NoteFolder -> ChatInfo 'CTLocal ContactRequest :: UserContactRequest -> ChatInfo 'CTContactRequest ContactConnection :: PendingContactConnection -> ChatInfo 'CTContactConnection deriving instance Show (ChatInfo c) -chatInfoChatTs :: ChatInfo c -> Maybe UTCTime -chatInfoChatTs = \case - DirectChat Contact {chatTs} -> chatTs - GroupChat GroupInfo {chatTs} -> chatTs - _ -> Nothing +data GroupChatScopeInfo + = GCSIMemberSupport {groupMember_ :: Maybe GroupMember} + deriving (Show) + +toChatScope :: GroupChatScopeInfo -> GroupChatScope +toChatScope = \case + GCSIMemberSupport {groupMember_} -> GCSMemberSupport $ groupMemberId' <$> groupMember_ + +toMsgScope :: GroupInfo -> GroupChatScopeInfo -> MsgScope +toMsgScope GroupInfo {membership} = \case + GCSIMemberSupport {groupMember_} -> MSMember $ memberId' $ fromMaybe membership groupMember_ chatInfoToRef :: ChatInfo c -> ChatRef chatInfoToRef = \case - DirectChat Contact {contactId} -> ChatRef CTDirect contactId - GroupChat GroupInfo {groupId} -> ChatRef CTGroup groupId - LocalChat NoteFolder {noteFolderId} -> ChatRef CTLocal noteFolderId - ContactRequest UserContactRequest {contactRequestId} -> ChatRef CTContactRequest contactRequestId - ContactConnection PendingContactConnection {pccConnId} -> ChatRef CTContactConnection pccConnId + DirectChat Contact {contactId} -> ChatRef CTDirect contactId Nothing + GroupChat GroupInfo {groupId} scopeInfo -> ChatRef CTGroup groupId (toChatScope <$> scopeInfo) + LocalChat NoteFolder {noteFolderId} -> ChatRef CTLocal noteFolderId Nothing + ContactRequest UserContactRequest {contactRequestId} -> ChatRef CTContactRequest contactRequestId Nothing + ContactConnection PendingContactConnection {pccConnId} -> ChatRef CTContactConnection pccConnId Nothing chatInfoMembership :: ChatInfo c -> Maybe GroupMember chatInfoMembership = \case - GroupChat GroupInfo {membership} -> Just membership + GroupChat GroupInfo {membership} _scopeInfo -> Just membership _ -> Nothing data JSONChatInfo = JCInfoDirect {contact :: Contact} - | JCInfoGroup {groupInfo :: GroupInfo} + | JCInfoGroup {groupInfo :: GroupInfo, groupChatScope :: Maybe GroupChatScopeInfo} | JCInfoLocal {noteFolder :: NoteFolder} | JCInfoContactRequest {contactRequest :: UserContactRequest} | JCInfoContactConnection {contactConnection :: PendingContactConnection} +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCSI") ''GroupChatScopeInfo) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCInfo") ''JSONChatInfo) instance ChatTypeI c => FromJSON (ChatInfo c) where @@ -125,7 +152,7 @@ instance ToJSON (ChatInfo c) where jsonChatInfo :: ChatInfo c -> JSONChatInfo jsonChatInfo = \case DirectChat c -> JCInfoDirect c - GroupChat g -> JCInfoGroup g + GroupChat g s -> JCInfoGroup g s LocalChat l -> JCInfoLocal l ContactRequest g -> JCInfoContactRequest g ContactConnection c -> JCInfoContactConnection c @@ -137,7 +164,7 @@ deriving instance Show AChatInfo jsonAChatInfo :: JSONChatInfo -> AChatInfo jsonAChatInfo = \case JCInfoDirect c -> AChatInfo SCTDirect $ DirectChat c - JCInfoGroup g -> AChatInfo SCTGroup $ GroupChat g + JCInfoGroup g s -> AChatInfo SCTGroup $ GroupChat g s JCInfoLocal l -> AChatInfo SCTLocal $ LocalChat l JCInfoContactRequest g -> AChatInfo SCTContactRequest $ ContactRequest g JCInfoContactConnection c -> AChatInfo SCTContactConnection $ ContactConnection c @@ -163,8 +190,6 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem } deriving (Show) -data NotInHistory = NotInHistory - data CIMention = CIMention { memberId :: MemberId, -- member record can be created later than the mention is received @@ -267,8 +292,8 @@ ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv - CDGroupSnd :: GroupInfo -> ChatDirection 'CTGroup 'MDSnd - CDGroupRcv :: GroupInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv + CDGroupSnd :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatDirection 'CTGroup 'MDSnd + CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv CDLocalSnd :: NoteFolder -> ChatDirection 'CTLocal 'MDSnd CDLocalRcv :: NoteFolder -> ChatDirection 'CTLocal 'MDRcv @@ -276,8 +301,8 @@ toCIDirection :: ChatDirection c d -> CIDirection c d toCIDirection = \case CDDirectSnd _ -> CIDirectSnd CDDirectRcv _ -> CIDirectRcv - CDGroupSnd _ -> CIGroupSnd - CDGroupRcv _ m -> CIGroupRcv m + CDGroupSnd _ _ -> CIGroupSnd + CDGroupRcv _ _ m -> CIGroupRcv m CDLocalSnd _ -> CILocalSnd CDLocalRcv _ -> CILocalRcv @@ -285,8 +310,8 @@ toChatInfo :: ChatDirection c d -> ChatInfo c toChatInfo = \case CDDirectSnd c -> DirectChat c CDDirectRcv c -> DirectChat c - CDGroupSnd g -> GroupChat g - CDGroupRcv g _ -> GroupChat g + CDGroupSnd g s -> GroupChat g s + CDGroupRcv g s _ -> GroupChat g s CDLocalSnd l -> LocalChat l CDLocalRcv l -> LocalChat l @@ -1374,6 +1399,8 @@ instance MsgDirectionI d => ToJSON (CIFile d) where toJSON = $(JQ.mkToJSON defaultJSON ''CIFile) toEncoding = $(JQ.mkToEncoding defaultJSON ''CIFile) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCS") ''GroupChatScope) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIDirection) instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 2281c1aefa..5a60bba255 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -77,12 +77,13 @@ import Simplex.Messaging.Version hiding (version) -- 11 - fix profile update in business chats (2024-12-05) -- 12 - support sending and receiving content reports (2025-01-03) -- 14 - support sending and receiving group join rejection (2025-02-24) +-- 15 - support specifying message scopes for group messages (2025-03-12) -- 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 14 +currentChatVersion = VersionChat 15 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -137,6 +138,10 @@ contentReportsVersion = VersionChat 12 groupJoinRejectVersion :: VersionChat groupJoinRejectVersion = VersionChat 14 +-- support group knocking (MsgScope) +groupKnockingVersion :: VersionChat +groupKnockingVersion = VersionChat 15 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -243,6 +248,12 @@ instance ToJSON SharedMsgId where toJSON = strToJSON toEncoding = strToJEncoding +data MsgScope + = MSMember {memberId :: MemberId} -- Admins can use any member id; members can use only their own id + deriving (Eq, Show) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MS") ''MsgScope) + $(JQ.deriveJSON defaultJSON ''AppMessageJson) data MsgRef = MsgRef @@ -319,7 +330,7 @@ data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMess data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -335,8 +346,8 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json - XGrpLinkAcpt :: GroupMemberRole -> ChatMsgEvent 'Json - XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json + XGrpLinkAcpt :: GroupMemberRole -> Maybe MemberId -> ChatMsgEvent 'Json -- Maybe MemberId for compatibility + XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json @@ -383,7 +394,7 @@ isForwardedGroupMsg ev = case ev of XMsgReact {} -> True XFileCancel _ -> True XInfo _ -> True - XGrpMemNew _ -> True + XGrpMemNew {} -> True XGrpMemRole {} -> True XGrpMemRestrict {} -> True XGrpMemDel {} -> True -- TODO there should be a special logic when deleting host member (e.g., host forwards it before deleting connections) @@ -640,7 +651,8 @@ data ExtMsgContent = ExtMsgContent mentions :: Map MemberName MsgMention, file :: Maybe FileInvitation, ttl :: Maybe Int, - live :: Maybe Bool + live :: Maybe Bool, + scope :: Maybe MsgScope } deriving (Eq, Show) @@ -720,10 +732,11 @@ parseMsgContainer v = ttl <- v .:? "ttl" live <- v .:? "live" mentions <- fromMaybe M.empty <$> (v .:? "mentions") - pure ExtMsgContent {content, mentions, file, ttl, live} + scope <- v .:? "scope" + pure ExtMsgContent {content, mentions, file, ttl, live, scope} extMsgContent :: MsgContent -> Maybe FileInvitation -> ExtMsgContent -extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing +extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing Nothing justTrue :: Bool -> Maybe Bool justTrue True = Just True @@ -772,8 +785,8 @@ msgContainerJSON = \case MCSimple mc -> o $ msgContent mc where o = JM.fromList - msgContent ExtMsgContent {content, mentions, file, ttl, live} = - ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) ["content" .= content] + msgContent ExtMsgContent {content, mentions, file, ttl, live, scope} = + ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) $ ("scope" .=? scope) ["content" .= content] nonEmptyMap :: Map k v -> Maybe (Map k v) nonEmptyMap m = if M.null m then Nothing else Just m @@ -983,8 +996,8 @@ toCMEventTag msg = case msg of XGrpLinkInv _ -> XGrpLinkInv_ XGrpLinkReject _ -> XGrpLinkReject_ XGrpLinkMem _ -> XGrpLinkMem_ - XGrpLinkAcpt _ -> XGrpLinkAcpt_ - XGrpMemNew _ -> XGrpMemNew_ + XGrpLinkAcpt {} -> XGrpLinkAcpt_ + XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ XGrpMemFwd _ _ -> XGrpMemFwd_ @@ -1070,7 +1083,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do msg = \case XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" - XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> (fromMaybe M.empty <$> opt "mentions") <*> opt "ttl" <*> opt "live" + XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> (fromMaybe M.empty <$> opt "mentions") <*> opt "ttl" <*> opt "live" <*> opt "scope" XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> p "reaction" <*> p "add" @@ -1086,8 +1099,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" - XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "role" - XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" + XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "role" <*> opt "memberId" + XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" XGrpMemFwd_ -> XGrpMemFwd <$> p "memberInfo" <*> p "memberIntro" @@ -1134,7 +1147,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgUpdate msgId' content mentions ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] + XMsgUpdate msgId' content mentions ttl live scope -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] @@ -1150,8 +1163,8 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] - XGrpLinkAcpt role -> o ["role" .= role] - XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] + XGrpLinkAcpt role memberId -> o $ ("memberId" .=? memberId) ["role" .= role] + XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] XGrpMemFwd memInfo memIntro -> o ["memberInfo" .= memInfo, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index d43f7169ea..8d2b1326e5 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -136,16 +136,17 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 95e169e400..81dbb4ca54 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -371,9 +371,9 @@ getXFTPRcvFileDBIds db aRcvFileId = toFileRef :: (FileTransferId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (Maybe ChatRef, FileTransferId) toFileRef = \case - (fileId, Just contactId, Nothing, Nothing) -> Right (Just $ ChatRef CTDirect contactId, fileId) - (fileId, Nothing, Just groupId, Nothing) -> Right (Just $ ChatRef CTGroup groupId, fileId) - (fileId, Nothing, Nothing, Just folderId) -> Right (Just $ ChatRef CTLocal folderId, fileId) + (fileId, Just contactId, Nothing, Nothing) -> Right (Just $ ChatRef CTDirect contactId Nothing, fileId) + (fileId, Nothing, Just groupId, Nothing) -> Right (Just $ ChatRef CTGroup groupId Nothing, fileId) + (fileId, Nothing, Nothing, Just folderId) -> Right (Just $ ChatRef CTLocal folderId Nothing, fileId) (fileId, _, _, _) -> Right (Nothing, fileId) updateFileCancelled :: MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> IO () @@ -444,8 +444,8 @@ getChatRefByFileId db user fileId = liftIO (lookupChatRefByFileId db user fileId lookupChatRefByFileId :: DB.Connection -> User -> Int64 -> IO (Maybe ChatRef) lookupChatRefByFileId db User {userId} fileId = getChatRef <&> \case - [(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId - [(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId + [(Just contactId, Nothing)] -> Just $ ChatRef CTDirect contactId Nothing + [(Nothing, Just groupId)] -> Just $ ChatRef CTGroup groupId Nothing _ -> Nothing where getChatRef = diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 0094e20cb8..36169c1a06 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -54,6 +54,7 @@ module Simplex.Chat.Store.Groups getGroupMemberByMemberId, getGroupMembers, getGroupModerators, + getSupportMembers, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupChatItems, @@ -88,6 +89,7 @@ module Simplex.Chat.Store.Groups updateIntroStatus, saveIntroInvitation, getIntroduction, + getIntroducedGroupMemberIds, getForwardIntroducedMembers, getForwardInvitedMembers, createIntroReMember, @@ -175,11 +177,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe BoolInt) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, 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, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, 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, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, supportChatUnanswered)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnanswered)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -278,16 +280,17 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -358,7 +361,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + modsSupportChat = Nothing } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -371,9 +375,9 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db vr user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership - MemberIdRole {memberId = memberId', memberRole = memberRole'} = invitedMember - liftIO . when (memberId /= memberId' || memberRole /= memberRole') $ - DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (memberId', memberRole', groupMemberId) + MemberIdRole {memberId = invMemberId, memberRole = memberRole'} = invitedMember + liftIO . when (memberId /= invMemberId || memberRole /= memberRole') $ + DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (invMemberId, memberRole', groupMemberId) gInfo' <- if p' == groupProfile then pure gInfo @@ -428,7 +432,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + modsSupportChat = Nothing }, groupMemberId ) @@ -475,7 +480,8 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe activeConn = Nothing, memberChatVRange, createdAt, - updatedAt = createdAt + updatedAt = createdAt, + supportChat = Nothing } where memberChatVRange@(VersionRange minV maxV) = vr @@ -523,7 +529,8 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { 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 let fromMemberProfile = profileFromName fromMemberName - initialStatus = maybe GSMemAccepted acceptanceToStatus accepted + fullGroupPreferences = mergeGroupPreferences $ groupPreferences groupProfile + initialStatus = maybe GSMemAccepted (acceptanceToStatus fullGroupPreferences) accepted createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) @@ -542,13 +549,13 @@ createGroupViaLink' invitedMember groupProfile business - memStatus = do + membershipStatus = do currentTs <- liftIO getCurrentTime groupId <- insertGroup_ currentTs hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember memStatus IBUnknown customUserProfileId currentTs vr + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId currentTs vr liftIO $ setViaGroupLinkHash db groupId connId (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where @@ -583,7 +590,7 @@ createGroupViaLink' user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCHostMember, memStatus, fromInvitedBy userContactId IBUnknown) + ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -764,9 +771,10 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -830,7 +838,7 @@ groupMemberQuery = SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -917,6 +925,14 @@ getGroupModerators db vr 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, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) +getSupportMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getSupportMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr 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.support_chat_ts IS NOT NULL") + (userId, userId, groupId, userContactId) + getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) @@ -1000,7 +1016,8 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, activeConn = Nothing, memberChatVRange = peerChatVRange, createdAt, - updatedAt = createdAt + updatedAt = createdAt, + supportChat = Nothing } where insertMember_ = @@ -1203,8 +1220,8 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do |] (memStatus, currentTs, userId, groupMemberId) -updateGroupMemberAccepted :: DB.Connection -> User -> GroupMember -> GroupMemberRole -> IO GroupMember -updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} role = do +updateGroupMemberAccepted :: DB.Connection -> User -> GroupMember -> GroupMemberStatus -> GroupMemberRole -> IO GroupMember +updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status role = do currentTs <- getCurrentTime DB.execute db @@ -1213,8 +1230,8 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} role = SET member_status = ?, member_role = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? |] - (GSMemConnected, role, currentTs, userId, groupMemberId) - pure m {memberStatus = GSMemConnected, memberRole = role, updatedAt = currentTs} + (status, role, currentTs, userId, groupMemberId) + pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember @@ -1298,7 +1315,8 @@ createNewMember_ activeConn, memberChatVRange, createdAt, - updatedAt = createdAt + updatedAt = createdAt, + supportChat = Nothing } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1396,8 +1414,8 @@ saveIntroInvitation db reMember toMember introInv@IntroInvitation {groupConnReq} (GMIntroInvReceived, groupConnReq, directConnReq introInv, currentTs, introId intro) pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} -saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> IO () -saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} = do +saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> GroupMemberStatus -> IO () +saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} newMemberStatus = do currentTs <- getCurrentTime DB.execute db @@ -1409,7 +1427,7 @@ saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnRe updated_at = ? WHERE group_member_id = ? |] - (GSMemIntroInvited, groupConnReq, directConnReq, currentTs, groupMemberId) + (newMemberStatus, groupConnReq, directConnReq, currentTs, groupMemberId) getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro getIntroduction db reMember toMember = ExceptT $ do @@ -1429,6 +1447,14 @@ getIntroduction db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound +getIntroducedGroupMemberIds :: DB.Connection -> GroupMember -> IO [GroupMemberId] +getIntroducedGroupMemberIds db invitee = + map fromOnly <$> + DB.query + db + "SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ?" + (Only $ groupMemberId' invitee) + getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] getForwardIntroducedMembers db vr user invitee highlyAvailable = do memberIds <- map fromOnly <$> query @@ -1545,16 +1571,17 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -2402,8 +2429,8 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g where VersionRange minV maxV = vr -updateUnknownMemberAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do +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} status = do _ <- updateMemberProfile db user unknownMember profile currentTs <- liftIO getCurrentTime liftIO $ @@ -2420,7 +2447,7 @@ updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMemb updated_at = ? WHERE user_id = ? AND group_member_id = ? |] - ( (memberRole, GCPostMember, GSMemAnnounced, groupMemberId' invitingMember) + ( (memberRole, GCPostMember, status, groupMemberId' invitingMember) :. (minV, maxV, currentTs, userId, groupMemberId) ) getGroupMemberById db vr user groupMemberId diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index ab7d866349..230028e1ce 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -41,6 +41,7 @@ module Simplex.Chat.Store.Messages getChatPreviews, getDirectChat, getGroupChat, + getGroupChatScopeInfoForItem, getLocalChat, getDirectChatItemLast, getAllChatItems, @@ -145,7 +146,7 @@ import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) +import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe) import Data.Ord (Down (..), comparing) import Data.Text (Text) import qualified Data.Text as T @@ -368,11 +369,21 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti db "UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ?" (chatTs, userId, contactId) - GroupChat GroupInfo {groupId} -> + GroupChat GroupInfo {groupId} Nothing -> DB.execute db "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" (chatTs, userId, groupId) + GroupChat GroupInfo {groupId} (Just (GCSIMemberSupport Nothing)) -> do + DB.execute + db + "UPDATE groups SET mods_support_chat_ts = ? WHERE user_id = ? AND group_id = ?" + (chatTs, userId, groupId) + GroupChat _gInfo (Just (GCSIMemberSupport (Just GroupMember {groupMemberId}))) -> do + DB.execute + db + "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" + (chatTs, groupMemberId) LocalChat NoteFolder {noteFolderId} -> DB.execute db @@ -380,9 +391,9 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti (chatTs, userId, noteFolderId) _ -> pure () -createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> Maybe NotInHistory -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId -createNewSndChatItem db user chatDirection notInHistory_ SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = - createNewChatItem_ db user chatDirection notInHistory_ createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -396,9 +407,9 @@ createNewSndChatItem db user chatDirection notInHistory_ SndMessage {msgId, shar CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> Maybe NotInHistory -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection notInHistory_ RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection notInHistory_ (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention itemTs createdAt = do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -410,24 +421,24 @@ createNewRcvChatItem db user chatDirection notInHistory_ RcvMessage {msgId, chat Just QuotedMsg {msgRef = MsgRef {msgId = sharedMsgId, sentAt, sent, memberId}, content} -> uncurry (sharedMsgId,Just sentAt,Just content,,) $ case chatDirection of CDDirectRcv _ -> (Just $ not sent, Nothing) - CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> + CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ _ -> (Just $ Just userMemberId == memberId, memberId) createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItemNoMsg db user chatDirection ciContent itemTs = - createNewChatItem_ db user chatDirection Nothing Nothing Nothing ciContent quoteRow Nothing Nothing False False itemTs Nothing + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing Nothing False False itemTs Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe NotInHistory -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection notInHistory_ msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention itemTs forwardedByMember createdAt = do DB.execute db [sql| INSERT INTO chat_items ( -- user and IDs - user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, + user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, @@ -435,9 +446,9 @@ createNewChatItem_ db User {userId} chatDirection notInHistory_ msgId_ sharedMsg quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, msgId_) :. idsRow :. itemRow :. quoteRow' :. forwardedFromRow) + ((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId @@ -445,18 +456,27 @@ createNewChatItem_ db User {userId} chatDirection notInHistory_ msgId_ sharedMsg itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt) :. (Maybe Int, Maybe UTCTime) itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> (justTrue live), BI userMention) :. ciTimedRow timed quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) - idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) + idsRow :: (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId, Maybe NoteFolderId) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) - CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId, Nothing) - CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing, Nothing) + CDGroupRcv GroupInfo {groupId} _ GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId, Nothing) + CDGroupSnd GroupInfo {groupId} _ -> (Nothing, Just groupId, Nothing, Nothing) CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) + groupScope :: Maybe (Maybe GroupChatScopeInfo) + groupScope = case chatDirection of + CDGroupRcv _ scope _ -> Just scope + CDGroupSnd _ scope -> Just scope + _ -> Nothing + groupScopeRow :: (Maybe GroupChatScopeTag, Maybe GroupMemberId) + groupScopeRow = case groupScope of + Just (Just GCSIMemberSupport {groupMember_}) -> (Just GCSTMemberSupport_, groupMemberId' <$> groupMember_) + _ -> (Nothing, Nothing) includeInHistory :: Bool - includeInHistory = - let (_, groupId_, _, _) = idsRow - in isJust groupId_ && isNothing notInHistory_ && isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) + includeInHistory = case groupScope of + Just Nothing -> isJust (ciMsgContent ciContent) && ((msgContentTag <$> ciMsgContent ciContent) /= Just MCReport_) + _ -> False forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) forwardedFromRow = case itemForwarded of Nothing -> @@ -479,7 +499,7 @@ getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirectio getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) - CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> + CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId @@ -524,7 +544,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -790,7 +810,7 @@ getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do lastItem <- case lastItemId_ of Just lastItemId -> (: []) <$> getGroupCIWithReactions db user groupInfo lastItemId Nothing -> pure [] - pure $ AChat SCTGroup (Chat (GroupChat groupInfo) lastItem stats) + pure $ AChat SCTGroup (Chat (GroupChat groupInfo Nothing) lastItem stats) findLocalChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findLocalChatPreviews_ db User {userId} pagination clq = @@ -1225,32 +1245,87 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChat db vr user groupId contentFilter pagination search_ = do +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId + scopeInfo <- mapM (getGroupChatScopeInfo db vr user g) scope_ case pagination of - CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g contentFilter count search emptyChatStats - CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g contentFilter afterId count search - CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g contentFilter beforeId count search - CPAround aroundId count -> getGroupChatAround_ db user g contentFilter aroundId count search + 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 + CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g scopeInfo contentFilter beforeId count search + CPAround aroundId count -> getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search CPInitial count -> do unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" - getGroupChatInitial_ db user g contentFilter count + getGroupChatInitial_ db user g scopeInfo contentFilter count -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> IO (Chat 'CTGroup) -getGroupChatLast_ db user g contentFilter count search stats = do - ciIds <- getGroupChatItemIDs db user g contentFilter GRLast count search - ts <- getCurrentTime - cis <- mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) stats +getGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo +getGroupChatScopeInfo db vr user GroupInfo {modsSupportChat} = \case + GCSMemberSupport Nothing -> case modsSupportChat of + Nothing -> throwError $ SEInternalError "no moderators support chat" + Just _modsSupportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} + GCSMemberSupport (Just gmId) -> do + m <- getGroupMemberById db vr user gmId + case supportChat m of + Nothing -> throwError $ SEInternalError "no support chat" + Just _supportChat -> 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) + +getGroupChatScopeForItem_ :: DB.Connection -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScope) +getGroupChatScopeForItem_ db itemId = + ExceptT . firstRow toScope (SEChatItemNotFound itemId) $ + DB.query + db + [sql| + SELECT group_scope_tag, group_scope_group_member_id + FROM chat_items + WHERE chat_item_id = ? + |] + (Only itemId) + where + toScope (scopeTag, scopeMemberId) = + case (scopeTag, scopeMemberId) of + (Just GCSTMemberSupport_, Just gmId) -> Just $ GCSMemberSupport gmId + (Just GCSTMemberSupport_, Nothing) -> Just $ GCSMemberSupport Nothing + (Nothing, Nothing) -> Nothing + (Nothing, Just _) -> Nothing -- shouldn't happen + +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do + ciIds <- getGroupChatItemIDs db user g scopeInfo_ contentFilter GRLast count search + ts <- liftIO getCurrentTime + cis <- mapM (liftIO . safeGetGroupItem db user g ts) ciIds + pure $ Chat (GroupChat g scopeInfo_) (reverse cis) stats data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId -getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> IO [ChatItemId] -getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range count search = case contentFilter of - Just mcTag -> idsQuery (baseCond <> " AND msg_content_tag = ? ") (userId, groupId, mcTag) - Nothing -> idsQuery baseCond (userId, groupId) +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> ExceptT StoreError IO [ChatItemId] +getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter range count search = case (scopeInfo_, contentFilter) of + (Nothing, Nothing) -> + liftIO $ + idsQuery + (baseCond <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL ") + (userId, groupId) + (Nothing, Just mcTag) -> + liftIO $ + idsQuery + (baseCond <> " AND msg_content_tag = ? ") + (userId, groupId, mcTag) + (Just GCSIMemberSupport {groupMember_ = Just m}, Nothing) -> + liftIO $ + idsQuery + (baseCond <> " AND group_scope_tag = ? AND group_scope_group_member_id = ? ") + (userId, groupId, GCSTMemberSupport_, groupMemberId' m) + (Just GCSIMemberSupport {groupMember_ = Nothing}, Nothing) -> + liftIO $ + idsQuery + (baseCond <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NULL ") + (userId, groupId, GCSTMemberSupport_) + (Just _scope, Just _mcTag) -> + throwError $ SEInternalError "group scope and content filter are not supported together" where baseQuery = " SELECT chat_item_id FROM chat_items WHERE " baseCond = " user_id = ? AND group_id = ? " @@ -1322,79 +1397,80 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatAfter_ db user g@GroupInfo {groupId} contentFilter afterId count search = do +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) - ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search + ciIds <- getGroupChatItemIDs db user g scopeInfo contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) cis emptyChatStats + pure $ Chat (GroupChat g scopeInfo) cis emptyChatStats -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatBefore_ db user g@GroupInfo {groupId} contentFilter beforeId count search = do +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) - ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search + ciIds <- getGroupChatItemIDs db user g scopeInfo contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) emptyChatStats + pure $ Chat (GroupChat g scopeInfo) (reverse cis) emptyChatStats -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround_ db user g contentFilter aroundId count search = do - stats <- liftIO $ getGroupStats_ db user g - getGroupChatAround' db user g contentFilter aroundId count search stats +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search = do + stats <- getGroupStats_ db user g scopeInfo + getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround' db user g contentFilter aroundId count search stats = do +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats = do aroundCI <- getGroupCIWithReactions db user g aroundId let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) afterRange = GRAfter (chatItemTs aroundCI) (cChatItemId aroundCI) - beforeIds <- liftIO $ getGroupChatItemIDs db user g contentFilter beforeRange count search - afterIds <- liftIO $ getGroupChatItemIDs db user g contentFilter afterRange count search + beforeIds <- getGroupChatItemIDs db user g scopeInfo contentFilter beforeRange count search + afterIds <- getGroupChatItemIDs db user g scopeInfo contentFilter afterRange count search ts <- liftIO getCurrentTime beforeCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) beforeIds afterCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) afterIds let cis = reverse beforeCIs <> [aroundCI] <> afterCIs navInfo <- liftIO $ getNavInfo cis - pure (Chat (GroupChat g) cis stats, Just navInfo) + pure (Chat (GroupChat g scopeInfo) cis stats, Just navInfo) where getNavInfo cis_ = case cis_ of [] -> pure $ NavigationInfo 0 0 cis -> getGroupNavInfo_ db user g (last cis) -getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatInitial_ db user g contentFilter count = do - liftIO (getGroupMinUnreadId_ db user g contentFilter) >>= \case +getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do + getGroupMinUnreadId_ db user g scopeInfo_ contentFilter >>= \case Just minUnreadItemId -> do - stats <- liftIO $ getStats minUnreadItemId =<< getGroupUnreadCount_ db user g Nothing - getGroupChatAround' db user g contentFilter minUnreadItemId count "" stats - Nothing -> liftIO $ do - stats <- getStats 0 (0, 0) - (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" stats + unreadCounts <- getGroupUnreadCount_ db user g scopeInfo_ Nothing + stats <- liftIO $ getStats minUnreadItemId unreadCounts + getGroupChatAround' db user g scopeInfo_ contentFilter minUnreadItemId count "" stats + Nothing -> do + stats <- liftIO $ getStats 0 (0, 0) + (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats where getStats minUnreadItemId (unreadCount, unreadMentions) = do reportsCount <- getGroupReportsCount_ db user g False pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} -getGroupStats_ :: DB.Connection -> User -> GroupInfo -> IO ChatStats -getGroupStats_ db user g = do - minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g Nothing - (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g Nothing - reportsCount <- getGroupReportsCount_ db user g False +getGroupStats_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> ExceptT StoreError IO ChatStats +getGroupStats_ db user g scopeInfo_ = do + minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g scopeInfo_ Nothing + (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g scopeInfo_ Nothing + reportsCount <- liftIO $ getGroupReportsCount_ db user g False pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} -getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Maybe ChatItemId) -getGroupMinUnreadId_ db user g contentFilter = +getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) +getGroupMinUnreadId_ db user g scopeInfo_ contentFilter = fmap join . maybeFirstRow fromOnly $ - queryUnreadGroupItems db user g contentFilter baseQuery orderLimit + queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery orderLimit where baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" -getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Int, Int) -getGroupUnreadCount_ db user g contentFilter = - head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" +getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Int, Int) +getGroupUnreadCount_ db user g scopeInfo_ contentFilter = + head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery "" where baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? " @@ -1406,19 +1482,35 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" (userId, groupId, MCReport_, BI archived) -queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Query -> Query -> IO [r] -queryUnreadGroupItems db User {userId} GroupInfo {groupId} contentFilter baseQuery orderLimit = - case contentFilter of - Just mcTag -> - DB.query - db - (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) - (userId, groupId, mcTag, CISRcvNew) - Nothing -> - DB.query - db - (baseQuery <> " AND item_status = ? " <> orderLimit) - (userId, groupId, CISRcvNew) +queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> ExceptT StoreError IO [r] +queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter baseQuery orderLimit = + case (scopeInfo_, contentFilter) of + (Nothing, Nothing) -> + liftIO $ + DB.query + db + (baseQuery <> " AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? " <> orderLimit) + (userId, groupId, CISRcvNew) + (Nothing, Just mcTag) -> + liftIO $ + DB.query + db + (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) + (userId, groupId, mcTag, CISRcvNew) + (Just GCSIMemberSupport {groupMember_ = Just m}, Nothing) -> + liftIO $ + DB.query + db + (baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id = ? AND item_status = ? " <> orderLimit) + (userId, groupId, GCSTMemberSupport_, groupMemberId' m, CISRcvNew) + (Just GCSIMemberSupport {groupMember_ = Nothing}, Nothing) -> + liftIO $ + DB.query + db + (baseQuery <> " AND group_scope_tag = ? AND group_scope_group_member_id IS NULL AND item_status = ? " <> orderLimit) + (userId, groupId, GCSTMemberSupport_, CISRcvNew) + (Just _scope, Just _mcTag) -> + throwError $ SEInternalError "group scope and content filter are not supported together" getGroupNavInfo_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO NavigationInfo getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do @@ -1693,9 +1785,11 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) toChatItemRef = \case - (itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId, itemId) - (itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId, itemId) - (itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId, itemId) + (itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId Nothing, itemId) + -- For "chat item refs" we don't care about scope, as they are only used for getting item by id (via getAChatItem). + -- To get the scope without joining group_members, we could additionally store group scope "tag" to differentiate member support chats. + (itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId Nothing, itemId) + (itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId Nothing, itemId) (itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> IO () @@ -1915,48 +2009,69 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing -- this function can be changed so it never fails, not only avoid failure on invalid json -toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do - chatItem $ fromRight invalid $ dbParseACIContent itemContentText - where - member_ = toMaybeGroupMember userContactId memberRow_ - quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - deletedByGroupMember_ = toMaybeGroupMember 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)) -> - Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent (maybeCIFile fileStatus) - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> - Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent Nothing - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> - Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent (maybeCIFile fileStatus) - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> - Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent Nothing - _ -> badItem - maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) - maybeCIFile fileStatus = - case (fileId_, fileName_, fileSize_, fileProtocol_) of - (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> - let cfArgs = CFArgs <$> fileKey <*> fileNonce - fileSource = (`CryptoFile` cfArgs) <$> filePath - in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} - _ -> Nothing - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup - cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} - badItem = Left $ SEBadChatItem itemId (Just itemTs) - ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d - ciMeta content status = - let itemDeleted' = case itemDeleted of - DBCINotDeleted -> Nothing - DBCIBlocked -> Just (CIBlocked deletedTs) - DBCIBlockedByAdmin -> Just (CIBlockedByAdmin deletedTs) - _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) - itemEdited' = maybe False unBI itemEdited - itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs forwardedByMember createdAt updatedAt - ciTimed :: Maybe CITimed - ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} +toGroupChatItem :: + UTCTime -> + Int64 -> + ChatItemRow + :. Only (Maybe GroupMemberId) + :. MaybeGroupMemberRow + :. GroupQuoteRow + :. MaybeGroupMemberRow -> + Either StoreError (CChatItem 'CTGroup) +toGroupChatItem + currentTs + userContactId + ( ( (itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) + :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) + :. forwardedFromRow + :. (timedTTL, timedDeleteAt, itemLive, BI userMention) + :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_) + ) + :. Only forwardedByMember + :. memberRow_ + :. (quoteRow :. quotedMemberRow_) + :. deletedByGroupMemberRow_ + ) = do + chatItem $ fromRight invalid $ dbParseACIContent itemContentText + where + member_ = toMaybeGroupMember userContactId memberRow_ + quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ + deletedByGroupMember_ = toMaybeGroupMember 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)) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent Nothing + _ -> badItem + maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) + maybeCIFile fileStatus = + case (fileId_, fileName_, fileSize_, fileProtocol_) of + (Just fileId, Just fileName, Just fileSize, Just fileProtocol) -> + let cfArgs = CFArgs <$> fileKey <*> fileNonce + fileSource = (`CryptoFile` cfArgs) <$> filePath + in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol} + _ -> Nothing + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup + cItem d chatDir ciStatus content file = + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + badItem = Left $ SEBadChatItem itemId (Just itemTs) + ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d + ciMeta content status = + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + DBCIBlocked -> Just (CIBlocked deletedTs) + DBCIBlockedByAdmin -> Just (CIBlockedByAdmin deletedTs) + _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) + itemEdited' = maybe False unBI itemEdited + itemForwarded = toCIForwardedFrom forwardedFromRow + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) userMention currentTs itemTs forwardedByMember createdAt updatedAt + ciTimed :: Maybe CITimed + ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do @@ -2583,21 +2698,23 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, - rm.created_at, rm.updated_at, + rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_unanswered, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, - dbm.created_at, dbm.updated_at + dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_unanswered FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id + LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id @@ -2796,24 +2913,26 @@ getChatRefViaItemId db User {userId} itemId = do DB.query db "SELECT contact_id, group_id FROM chat_items WHERE user_id = ? AND chat_item_id = ?" (userId, itemId) where toChatRef = \case - (Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId - (Nothing, Just groupId) -> Right $ ChatRef CTGroup groupId + (Just contactId, Nothing) -> Right $ ChatRef CTDirect contactId Nothing + -- Only used in CLI and unused APIs + (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 itemId = do - aci <- case chatRef of - ChatRef CTDirect contactId -> do - ct <- getContact db vr user contactId - (CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId +getAChatItem db vr user (ChatRef cType chatId scope) itemId = do + aci <- case cType of + CTDirect -> do + ct <- getContact db vr user chatId + (CChatItem msgDir ci) <- getDirectChatItem db user chatId itemId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - ChatRef CTGroup groupId -> do - gInfo <- getGroupInfo db vr user groupId - (CChatItem msgDir ci) <- getGroupChatItem db user groupId itemId - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci - ChatRef CTLocal folderId -> do - nf <- getNoteFolder db user folderId - CChatItem msgDir ci <- getLocalChatItem db user folderId itemId + CTGroup -> do + gInfo <- getGroupInfo db vr user chatId + (CChatItem msgDir ci) <- getGroupChatItem db user chatId itemId + scopeInfo <- mapM (getGroupChatScopeInfo db vr user gInfo) scope + pure $ AChatItem SCTGroup msgDir (GroupChat gInfo scopeInfo) ci + CTLocal -> do + nf <- getNoteFolder db user chatId + CChatItem msgDir ci <- getLocalChatItem db user chatId itemId pure $ AChatItem SCTLocal msgDir (LocalChat nf) ci _ -> throwError $ SEChatItemNotFound itemId liftIO $ getACIReactions db aci @@ -2823,9 +2942,9 @@ getAChatItemBySharedMsgId db user cd sharedMsgId = case cd of CDDirectRcv ct@Contact {contactId} -> do (CChatItem msgDir ci) <- getDirectChatItemBySharedMsgId db user contactId sharedMsgId pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci - CDGroupRcv g GroupMember {groupMemberId} -> do + CDGroupRcv g scopeInfo GroupMember {groupMemberId} -> do (CChatItem msgDir ci) <- getGroupChatItemBySharedMsgId db user g groupMemberId sharedMsgId - pure $ AChatItem SCTGroup msgDir (GroupChat g) ci + pure $ AChatItem SCTGroup msgDir (GroupChat g scopeInfo) ci getChatItemVersions :: DB.Connection -> ChatItemId -> IO [ChatItemVersion] getChatItemVersions db itemId = do @@ -2906,7 +3025,7 @@ getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemShar DirectChat ct -> do reactions <- getDirectCIReactions db ct itemSharedMId pure $ AChatItem SCTDirect md chat ci {reactions} - GroupChat g -> do + GroupChat g _s -> do let GroupMember {memberId} = chatItemMember g ci reactions <- getGroupCIReactions db g memberId itemSharedMId pure $ AChatItem SCTGroup md chat ci {reactions} @@ -3018,16 +3137,23 @@ getTimedItems db User {userId} startTimedThreadCutoff = <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, timed_delete_at + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, timed_delete_at FROM chat_items WHERE user_id = ? AND timed_delete_at IS NOT NULL AND timed_delete_at <= ? |] (userId, startTimedThreadCutoff) where - toCIRefDeleteAt :: (ChatItemId, Maybe ContactId, Maybe GroupId, UTCTime) -> Maybe ((ChatRef, ChatItemId), UTCTime) + toCIRefDeleteAt :: (ChatItemId, Maybe ContactId, Maybe GroupId, Maybe GroupChatScopeTag, Maybe GroupMemberId, UTCTime) -> Maybe ((ChatRef, ChatItemId), UTCTime) toCIRefDeleteAt = \case - (itemId, Just contactId, Nothing, deleteAt) -> Just ((ChatRef CTDirect contactId, itemId), deleteAt) - (itemId, Nothing, Just groupId, deleteAt) -> Just ((ChatRef CTGroup groupId, itemId), deleteAt) + (itemId, Just contactId, Nothing, Nothing, Nothing, deleteAt) -> + Just ((ChatRef CTDirect contactId Nothing, itemId), deleteAt) + (itemId, Nothing, Just groupId, scopeTag_, scopeGMId_, deleteAt) -> + let scope = case (scopeTag_, scopeGMId_) of + (Nothing, Nothing) -> Nothing + (Just GCSTMemberSupport_, Just groupMemberId) -> Just $ GCSMemberSupport (Just groupMemberId) + (Just GCSTMemberSupport_, Nothing) -> Just $ GCSMemberSupport Nothing + (Nothing, Just _) -> Nothing -- should not happen + in Just ((ChatRef CTGroup groupId scope, itemId), deleteAt) _ -> Nothing getChatItemTTL :: DB.Connection -> User -> IO Int64 @@ -3199,7 +3325,6 @@ getGroupSndStatusCounts db itemId = |] (Only itemId) --- TODO [knocking] filter out messages sent to member only getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> GroupMember -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do ciIds <- getLastItemIds_ diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 5865cd180e..5aec894ee7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -128,6 +128,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250122_chat_items_include_in_hist import Simplex.Chat.Store.SQLite.Migrations.M20250126_mentions import Simplex.Chat.Store.SQLite.Migrations.M20250129_delete_unused_contacts import Simplex.Chat.Store.SQLite.Migrations.M20250130_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -255,7 +256,8 @@ schemaMigrations = ("20250122_chat_items_include_in_history", m20250122_chat_items_include_in_history, Just down_m20250122_chat_items_include_in_history), ("20250126_mentions", m20250126_mentions, Just down_m20250126_mentions), ("20250129_delete_unused_contacts", m20250129_delete_unused_contacts, Just down_m20250129_delete_unused_contacts), - ("20250130_indexes", m20250130_indexes, Just down_m20250130_indexes) + ("20250130_indexes", m20250130_indexes, Just down_m20250130_indexes), + ("20250310_group_scope", m20250310_group_scope, Just down_m20250310_group_scope) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs new file mode 100644 index 0000000000..11db5f7084 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs @@ -0,0 +1,54 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- - group_scope_group_member_id points either to member (for chat with member as admin), +-- or to membership (for chat with admins as member); it is used to find chat items for scope, +-- when we know from context what group member id we are looking for; +-- - to learn scope of chat item when context is not known, group member is joined and scope +-- is decided based on whether member is of user member category (membership -> chat with admins), or not. +-- TODO [knocking] TBC schema +-- TODO - group_members.support_chat_unanswered - don't persist, calculate on the fly? +-- TODO - review indexes (drop idx_chat_items_groups_item_ts?) +m20250310_group_scope :: Query +m20250310_group_scope = + [sql| +ALTER TABLE groups ADD COLUMN mods_support_chat_ts TEXT; +ALTER TABLE groups ADD COLUMN mods_support_chat_unanswered INTEGER; + +ALTER TABLE group_members ADD COLUMN support_chat_ts TEXT; +ALTER TABLE group_members ADD COLUMN support_chat_unanswered INTEGER; + +ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; +ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +CREATE INDEX idx_chat_items_group_scope_group_member_id ON chat_items(group_scope_group_member_id); + +CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_ts +); +|] + +down_m20250310_group_scope :: Query +down_m20250310_group_scope = + [sql| +DROP INDEX idx_chat_items_group_scope_item_ts; + +DROP INDEX idx_chat_items_group_scope_group_member_id; + +ALTER TABLE chat_items DROP COLUMN group_scope_tag; +ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; + +ALTER TABLE group_members DROP COLUMN support_chat_ts; +ALTER TABLE group_members DROP COLUMN support_chat_unanswered; + +ALTER TABLE groups DROP COLUMN mods_support_chat_ts; +ALTER TABLE groups DROP COLUMN mods_support_chat_unanswered; +|] 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 9cbd5965b7..195617c38d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -37,16 +37,17 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -483,7 +484,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -494,7 +495,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -558,7 +559,7 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -707,21 +708,23 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, - rm.created_at, rm.updated_at, + rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_unanswered, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, - dbm.created_at, dbm.updated_at + dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_unanswered FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id + LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id @@ -734,6 +737,7 @@ Query: Plan: SEARCH i USING INTEGER PRIMARY KEY (rowid=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN +SEARCH gsm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH ri USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) LEFT-JOIN @@ -791,16 +795,17 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -836,9 +841,10 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -977,7 +983,7 @@ Query: LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: @@ -2910,7 +2916,7 @@ Plan: SEARCH chat_items USING INDEX chat_items_note_folder_id (note_folder_id=?) Query: - SELECT chat_item_id, contact_id, group_id, timed_delete_at + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, timed_delete_at FROM chat_items WHERE user_id = ? AND timed_delete_at IS NOT NULL AND timed_delete_at <= ? @@ -3106,6 +3112,14 @@ Query: Plan: SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) +Query: + SELECT group_scope_tag, group_scope_group_member_id + FROM chat_items + WHERE chat_item_id = ? + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT group_snd_item_status FROM group_snd_item_statuses @@ -3916,7 +3930,7 @@ Plan: Query: INSERT INTO chat_items ( -- user and IDs - user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, + user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, timed_ttl, timed_delete_at, @@ -3924,7 +3938,7 @@ Query: quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -4452,11 +4466,12 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered 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 @@ -4474,11 +4489,12 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered 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 @@ -4494,7 +4510,7 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4526,7 +4542,7 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4550,7 +4566,7 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4574,7 +4590,7 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4598,7 +4614,7 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4622,7 +4638,7 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4642,6 +4658,30 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) +Query: + SELECT + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.support_chat_ts IS NOT NULL +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +CORRELATED SCALAR SUBQUERY 1 +SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) + Query: SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role FROM user_contact_links @@ -4656,7 +4696,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -4918,36 +4958,44 @@ Query: Plan: SCAN usage_conditions -Query: SELECT chat_item_id FROM chat_items WHERE (( user_id = ? AND group_id = ? AND item_ts < ? ) OR ( user_id = ? AND group_id = ? AND item_ts = ? AND chat_item_id < ? )) ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Query: SELECT chat_item_id FROM chat_items WHERE (( user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_ts < ? ) OR ( user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_ts = ? AND chat_item_id < ? )) ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? Plan: MULTI-INDEX OR INDEX 1 -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts ? ) OR ( user_id = ? AND group_id = ? AND item_ts = ? AND chat_item_id > ? )) ORDER BY item_ts ASC, chat_item_id ASC LIMIT ? +Query: SELECT chat_item_id FROM chat_items WHERE (( user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_ts > ? ) OR ( user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_ts = ? AND chat_item_id > ? )) ORDER BY item_ts ASC, chat_item_id ASC LIMIT ? Plan: MULTI-INDEX OR INDEX 1 -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_ts>?) INDEX 2 -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=? AND item_ts=? AND rowid>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_ts=? AND rowid>?) USE TEMP B-TREE FOR ORDER BY -Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag = ? AND group_scope_group_member_id = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag = ? AND group_scope_group_member_id IS NULL ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) + +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=?) Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_item_ts (user_id=? AND group_id=? AND msg_content_tag=?) -Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) - Query: CREATE TABLE temp_conn_ids (conn_id BLOB) Error: SQLite3 returned ErrorError while attempting to perform prepare "explain query plan CREATE TABLE temp_conn_ids (conn_id BLOB)": table temp_conn_ids already exists @@ -5131,6 +5179,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) @@ -5154,6 +5203,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) @@ -5288,7 +5338,7 @@ SEARCH protocol_servers USING COVERING INDEX idx_smp_servers_user_id (user_id=?) SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) SEARCH commands USING COVERING INDEX idx_commands_user_id (user_id=?) SEARCH calls USING COVERING INDEX idx_calls_user_id (user_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_ts (user_id=?) SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=?) SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) SEARCH connections USING COVERING INDEX idx_connections_group_member (user_id=?) @@ -5445,9 +5495,9 @@ Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) -Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND item_status = ? +Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? Plan: @@ -5473,9 +5523,9 @@ Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) -Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1 +Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1 Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? LIMIT 1 Plan: @@ -5629,6 +5679,10 @@ Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connectio Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ? +Plan: +SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) + Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -5829,11 +5883,7 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE group_members SET member_status='pending_approval' WHERE group_member_id = 1 -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - -Query: UPDATE group_members SET member_status='pending_approval' WHERE group_member_id = 2 +Query: UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -5865,6 +5915,10 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET mods_support_chat_ts = ? WHERE user_id = ? AND group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET send_rcpts = NULL Plan: SCAN groups diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index f4cf2bccb8..22cb883a14 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -133,7 +133,9 @@ CREATE TABLE groups( business_xcontact_id BLOB NULL, customer_member_id BLOB NULL, chat_item_ttl INTEGER, - local_alias TEXT DEFAULT '', -- received + local_alias TEXT DEFAULT '', + mods_support_chat_ts TEXT, + mods_support_chat_unanswered INTEGER, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -166,6 +168,8 @@ CREATE TABLE group_members( peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, member_restriction TEXT, + support_chat_ts TEXT, + support_chat_unanswered INTEGER, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -407,7 +411,9 @@ CREATE TABLE chat_items( via_proxy INTEGER, msg_content_tag TEXT, include_in_history INTEGER NOT NULL DEFAULT 0, - user_mention INTEGER NOT NULL DEFAULT 0 + user_mention INTEGER NOT NULL DEFAULT 0, + group_scope_tag TEXT, + group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -1018,3 +1024,13 @@ CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( group_id, shared_msg_id ); +CREATE INDEX idx_chat_items_group_scope_group_member_id ON chat_items( + group_scope_group_member_id +); +CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_ts +); diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index a556fd0a49..adb20d1d33 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -577,27 +577,33 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. (Maybe UTCTime, Maybe BoolInt) :. GroupMemberRow -type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) +type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Maybe BoolInt) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. (modsSupportChatTs_, modsSupportChatUnanswered_) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} + modsSupportChat = case (modsSupportChatTs_, modsSupportChatUnanswered_) of + (Just modsChatTs, unanswered_) -> Just GroupSupportChat {chatTs = modsChatTs, unanswered = maybe False unBI unanswered_} + _ -> Nothing + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData, modsSupportChat} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnanswered_)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + supportChat = case (supportChatTs_, supportChatUnanswered_) of + (Just chatTs, unanswered_) -> Just GroupSupportChat {chatTs, unanswered = maybe False unBI unanswered_} + _ -> Nothing in GroupMember {..} toBusinessChatInfo :: BusinessChatInfoRow -> Maybe BusinessChatInfo @@ -612,11 +618,12 @@ groupInfoQuery = g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at + mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered 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 diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 3694b20c67..b1eccec251 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -182,7 +182,7 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) - (GroupChat g, CIGroupRcv m) -> sendNtf (fromGroup_ g m, text) + (GroupChat g scopeInfo, CIGroupRcv m) -> sendNtf (fromGroup_ g scopeInfo m, text) _ -> pure () where text = msgText mc formattedText @@ -206,6 +206,8 @@ responseNotification t@ChatTerminal {sendNotification} cc = \case sendNtf ("#" <> viewGroupName g, "you are connected to group") CRJoinedGroupMember u g m -> when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + CRJoinedGroupMemberConnecting u g _ m | memberStatus m == GSMemPendingReview -> + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is pending review") CRConnectedToGroupMember u g m _ -> when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> @@ -232,7 +234,7 @@ chatActiveTo (ChatName cType name) = case cType of chatInfoActiveTo :: ChatInfo c -> String chatInfoActiveTo = \case DirectChat c -> contactActiveTo c - GroupChat g -> groupActiveTo g + GroupChat g _scopeInfo -> groupActiveTo g _ -> "" contactActiveTo :: Contact -> String diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2bc9e725e5..7d09c54210 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -419,7 +419,14 @@ data GroupInfo = GroupInfo chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, - customData :: Maybe CustomData + customData :: Maybe CustomData, + modsSupportChat :: Maybe GroupSupportChat + } + deriving (Eq, Show) + +data GroupSupportChat = GroupSupportChat + { chatTs :: UTCTime, + unanswered :: Bool } deriving (Eq, Show) @@ -825,7 +832,8 @@ data GroupMember = GroupMember -- but it's correctly set on read (see toGroupInfo) memberChatVRange :: VersionRangeChat, createdAt :: UTCTime, - updatedAt :: UTCTime + updatedAt :: UTCTime, + supportChat :: Maybe GroupSupportChat } deriving (Eq, Show) @@ -856,6 +864,9 @@ supportsVersion m v = maxVersion (memberChatVRange' m) >= v groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId +memberId' :: GroupMember -> MemberId +memberId' GroupMember {memberId} = memberId + memberIncognito :: GroupMember -> IncognitoEnabled memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId @@ -999,6 +1010,7 @@ data GroupMemberStatus | GSMemUnknown -- unknown member, whose message was forwarded by an admin (likely member wasn't introduced due to not being a current member, but message was included in history) | GSMemInvited -- member is sent to or received invitation to join the group | GSMemPendingApproval -- member is connected to host but pending host approval before connecting to other members ("knocking") + | GSMemPendingReview -- member is introduced to admins but pending admin review before connecting to other members ("knocking") | GSMemIntroduced -- user received x.grp.mem.intro for this member (only with GCPreMember) | GSMemIntroInvited -- member is sent to or received from intro invitation | GSMemAccepted -- member accepted invitation (only User and Invitee) @@ -1019,10 +1031,11 @@ instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode -acceptanceToStatus :: GroupAcceptance -> GroupMemberStatus -acceptanceToStatus = \case - GAAccepted -> GSMemAccepted - GAPending -> GSMemPendingApproval +acceptanceToStatus :: FullGroupPreferences -> GroupAcceptance -> GroupMemberStatus +acceptanceToStatus prefs groupAcceptance + | groupAcceptance == GAPending = GSMemPendingApproval + | groupFeatureAllowed' SGFNewMemberReview prefs = GSMemPendingReview + | otherwise = GSMemAccepted memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of @@ -1033,6 +1046,7 @@ memberActive m = case memberStatus m of GSMemUnknown -> False GSMemInvited -> False GSMemPendingApproval -> True + GSMemPendingReview -> True GSMemIntroduced -> False GSMemIntroInvited -> False GSMemAccepted -> False @@ -1044,6 +1058,15 @@ memberActive m = case memberStatus m of memberCurrent :: GroupMember -> Bool memberCurrent = memberCurrent' . memberStatus +memberPending :: GroupMember -> Bool +memberPending m = case memberStatus m of + GSMemPendingApproval -> True + GSMemPendingReview -> True + _ -> False + +memberCurrentOrPending :: GroupMember -> Bool +memberCurrentOrPending m = memberCurrent m || memberPending m + -- update getGroupSummary if this is changed memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case @@ -1054,6 +1077,7 @@ memberCurrent' = \case GSMemUnknown -> False GSMemInvited -> False GSMemPendingApproval -> False + GSMemPendingReview -> False GSMemIntroduced -> True GSMemIntroInvited -> True GSMemAccepted -> True @@ -1071,6 +1095,7 @@ memberRemoved m = case memberStatus m of GSMemUnknown -> False GSMemInvited -> False GSMemPendingApproval -> False + GSMemPendingReview -> False GSMemIntroduced -> False GSMemIntroInvited -> False GSMemAccepted -> False @@ -1088,6 +1113,7 @@ instance TextEncoding GroupMemberStatus where "unknown" -> Just GSMemUnknown "invited" -> Just GSMemInvited "pending_approval" -> Just GSMemPendingApproval + "pending_review" -> Just GSMemPendingReview "introduced" -> Just GSMemIntroduced "intro-inv" -> Just GSMemIntroInvited "accepted" -> Just GSMemAccepted @@ -1104,6 +1130,7 @@ instance TextEncoding GroupMemberStatus where GSMemUnknown -> "unknown" GSMemInvited -> "invited" GSMemPendingApproval -> "pending_approval" + GSMemPendingReview -> "pending_review" GSMemIntroduced -> "introduced" GSMemIntroInvited -> "intro-inv" GSMemAccepted -> "accepted" @@ -1822,6 +1849,8 @@ $(JQ.deriveJSON defaultJSON ''Connection) $(JQ.deriveJSON defaultJSON ''PendingContactConnection) +$(JQ.deriveJSON defaultJSON ''GroupSupportChat) + $(JQ.deriveJSON defaultJSON ''GroupMember) $(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 63d80657dc..8098db9b50 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -152,6 +152,7 @@ data GroupFeature | GFSimplexLinks | GFReports | GFHistory + | GFNewMemberReview deriving (Show) data SGroupFeature (f :: GroupFeature) where @@ -164,6 +165,7 @@ data SGroupFeature (f :: GroupFeature) where SGFSimplexLinks :: SGroupFeature 'GFSimplexLinks SGFReports :: SGroupFeature 'GFReports SGFHistory :: SGroupFeature 'GFHistory + SGFNewMemberReview :: SGroupFeature 'GFNewMemberReview deriving instance Show (SGroupFeature f) @@ -190,6 +192,7 @@ groupFeatureNameText = \case GFSimplexLinks -> "SimpleX links" GFReports -> "Member reports" GFHistory -> "Recent history" + GFNewMemberReview -> "New member review" groupFeatureNameText' :: SGroupFeature f -> Text groupFeatureNameText' = groupFeatureNameText . toGroupFeature @@ -213,11 +216,12 @@ allGroupFeatures = AGF SGFFiles, AGF SGFSimplexLinks, AGF SGFReports, - AGF SGFHistory + AGF SGFHistory, + AGF SGFNewMemberReview ] groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, newMemberReview} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -227,6 +231,7 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac SGFSimplexLinks -> simplexLinks SGFReports -> reports SGFHistory -> history + SGFNewMemberReview -> newMemberReview toGroupFeature :: SGroupFeature f -> GroupFeature toGroupFeature = \case @@ -239,6 +244,7 @@ toGroupFeature = \case SGFSimplexLinks -> GFSimplexLinks SGFReports -> GFReports SGFHistory -> GFHistory + SGFNewMemberReview -> GFNewMemberReview class GroupPreferenceI p where getGroupPreference :: SGroupFeature f -> p -> GroupFeaturePreference f @@ -250,7 +256,7 @@ instance GroupPreferenceI (Maybe GroupPreferences) where getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) instance GroupPreferenceI FullGroupPreferences where - getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, newMemberReview} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -260,6 +266,7 @@ instance GroupPreferenceI FullGroupPreferences where SGFSimplexLinks -> simplexLinks SGFReports -> reports SGFHistory -> history + SGFNewMemberReview -> newMemberReview {-# INLINE getGroupPreference #-} -- collection of optional group preferences @@ -272,7 +279,8 @@ data GroupPreferences = GroupPreferences files :: Maybe FilesGroupPreference, simplexLinks :: Maybe SimplexLinksGroupPreference, reports :: Maybe ReportsGroupPreference, - history :: Maybe HistoryGroupPreference + history :: Maybe HistoryGroupPreference, + newMemberReview :: Maybe NewMemberReviewGroupPreference } deriving (Eq, Show) @@ -307,6 +315,7 @@ setGroupPreference_ f pref prefs = SGFSimplexLinks -> prefs {simplexLinks = pref} SGFReports -> prefs {reports = pref} SGFHistory -> prefs {history = pref} + SGFNewMemberReview -> prefs {newMemberReview = pref} setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences setGroupTimedMessagesPreference pref prefs_ = @@ -336,7 +345,8 @@ data FullGroupPreferences = FullGroupPreferences files :: FilesGroupPreference, simplexLinks :: SimplexLinksGroupPreference, reports :: ReportsGroupPreference, - history :: HistoryGroupPreference + history :: HistoryGroupPreference, + newMemberReview :: NewMemberReviewGroupPreference } deriving (Eq, Show) @@ -394,11 +404,12 @@ defaultGroupPrefs = files = FilesGroupPreference {enable = FEOn, role = Nothing}, simplexLinks = SimplexLinksGroupPreference {enable = FEOn, role = Nothing}, reports = ReportsGroupPreference {enable = FEOn}, - history = HistoryGroupPreference {enable = FEOff} + history = HistoryGroupPreference {enable = FEOff}, + newMemberReview = NewMemberReviewGroupPreference {enable = FEOff} } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice} = @@ -425,7 +436,8 @@ defaultBusinessGroupPrefs = files = Just $ FilesGroupPreference FEOn Nothing, simplexLinks = Just $ SimplexLinksGroupPreference FEOn Nothing, reports = Just $ ReportsGroupPreference FEOff, - history = Just $ HistoryGroupPreference FEOn + history = Just $ HistoryGroupPreference FEOn, + newMemberReview = Just $ NewMemberReviewGroupPreference FEOff } data TimedMessagesPreference = TimedMessagesPreference @@ -533,6 +545,10 @@ data HistoryGroupPreference = HistoryGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show) +data NewMemberReviewGroupPreference = NewMemberReviewGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show) + class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where type GroupFeaturePreference (f :: GroupFeature) = p | p -> f sGroupFeature :: SGroupFeature f @@ -573,6 +589,9 @@ instance HasField "enable" ReportsGroupPreference GroupFeatureEnabled where instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where hasField p@HistoryGroupPreference {enable} = (\e -> p {enable = e}, enable) +instance HasField "enable" NewMemberReviewGroupPreference GroupFeatureEnabled where + hasField p@NewMemberReviewGroupPreference {enable} = (\e -> p {enable = e}, enable) + instance GroupFeatureI 'GFTimedMessages where type GroupFeaturePreference 'GFTimedMessages = TimedMessagesGroupPreference sGroupFeature = SGFTimedMessages @@ -627,6 +646,12 @@ instance GroupFeatureI 'GFHistory where groupPrefParam _ = Nothing groupPrefRole _ = Nothing +instance GroupFeatureI 'GFNewMemberReview where + type GroupFeaturePreference 'GFNewMemberReview = NewMemberReviewGroupPreference + sGroupFeature = SGFNewMemberReview + groupPrefParam _ = Nothing + groupPrefRole _ = Nothing + instance GroupFeatureNoRoleI 'GFTimedMessages instance GroupFeatureNoRoleI 'GFFullDelete @@ -637,6 +662,8 @@ instance GroupFeatureNoRoleI 'GFReports instance GroupFeatureNoRoleI 'GFHistory +instance GroupFeatureNoRoleI 'GFNewMemberReview + instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role) @@ -790,7 +817,8 @@ mergeGroupPreferences groupPreferences = files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, reports = pref SGFReports, - history = pref SGFHistory + history = pref SGFHistory, + newMemberReview = pref SGFNewMemberReview } where pref :: SGroupFeature f -> GroupFeaturePreference f @@ -807,7 +835,8 @@ toGroupPreferences groupPreferences = files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, reports = pref SGFReports, - history = pref SGFHistory + history = pref SGFHistory, + newMemberReview = pref SGFNewMemberReview } where pref :: SGroupFeature f -> Maybe (GroupFeaturePreference f) @@ -919,6 +948,8 @@ $(J.deriveJSON defaultJSON ''ReportsGroupPreference) $(J.deriveJSON defaultJSON ''HistoryGroupPreference) +$(J.deriveJSON defaultJSON ''NewMemberReviewGroupPreference) + $(J.deriveJSON defaultJSON ''GroupPreferences) instance ToField GroupPreferences where diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index 4c6adae4e9..832d877234 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -51,11 +51,6 @@ instance ToJSON GroupMemberRole where data GroupAcceptance = GAAccepted | GAPending deriving (Eq, Show) --- TODO [knocking] encoding doesn't match field type -instance FromField GroupAcceptance where fromField = blobFieldDecoder strDecode - -instance ToField GroupAcceptance where toField = toField . strEncode - instance StrEncoding GroupAcceptance where strEncode = \case GAAccepted -> "accepted" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5053f24ec6..db5be7b734 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -186,6 +186,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g + CRMemberSupportChats u _g ms -> ttyUser u $ viewSupportMembers ms + -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] + -- CRGroupConversationsDeleted u _g _conversations -> ttyUser u [] CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> ttyUser u $ @@ -298,10 +301,12 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m + CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m + CRMemberAcceptedByOther u g acceptingMember m -> ttyUser u $ viewMemberAcceptedByOther g acceptingMember m CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] - CRJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - CRConnectedToGroupMember u g m _ -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] + CRJoinedGroupMemberConnecting u g host m -> ttyUser u $ viewJoinedGroupMemberConnecting g host m + CRConnectedToGroupMember u g m _ -> ttyUser u $ viewConnectedToGroupMember g m CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked @@ -473,7 +478,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe where toChatView :: AChat -> (Text, Text, Maybe ConnStatus) toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn) - toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) + toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName} _scopeInfo) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing) toChatView (AChat _ (Chat (LocalChat _) items _)) = ("*", toCIPreview items Nothing, Nothing) toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing) toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus) @@ -519,7 +524,7 @@ userNtf User {showNtfs, activeUser} = showNtfs || activeUser chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool chatDirNtf user cInfo chatDir mention = case (cInfo, chatDir) of (DirectChat ct, CIDirectRcv) -> contactNtf user ct mention - (GroupChat g, CIGroupRcv m) -> groupNtf user g mention && not (blockedByAdmin m) && showMessages (memberSettings m) + (GroupChat g _scopeInfo, CIGroupRcv m) -> groupNtf user g mention && not (blockedByAdmin m) && showMessages (memberSettings m) _ -> True contactNtf :: User -> Contact -> Bool -> Bool @@ -589,7 +594,7 @@ viewChats ts tz = concatMap chatPreview . reverse where chatName = case chat of DirectChat ct -> [" " <> ttyToContact' ct] - GroupChat g -> [" " <> ttyToGroup g] + GroupChat g scopeInfo -> [" " <> ttyToGroup g scopeInfo] _ -> [] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] @@ -617,22 +622,22 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa (maybe [] forwardedFrom itemForwarded) (directQuote chatDir) quotedItem - GroupChat g -> case chatDir of + GroupChat g scopeInfo -> case chatDir of CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to context mc CISndGroupInvitation {} -> showSndItemProhibited to _ -> showSndItem to where - to = ttyToGroup g + to = ttyToGroup g scopeInfo CIGroupRcv m -> case content of CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta CIRcvGroupInvitation {} -> showRcvItemProhibited from - CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False - CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False + CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False + CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False _ -> showRcvItem from where - from = ttyFromGroupAttention g m userMention + from = ttyFromGroupAttention g scopeInfo m userMention where context = maybe @@ -710,8 +715,8 @@ viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTime fwdDir_ = case (fwdMsgDir, fwdChatInfo) of (SMDSnd, DirectChat ct) -> Just $ "you @" <> viewContactName ct (SMDRcv, DirectChat ct) -> Just $ "@" <> viewContactName ct - (SMDSnd, GroupChat gInfo) -> Just $ "you #" <> viewGroupName gInfo - (SMDRcv, GroupChat gInfo) -> Just $ "#" <> viewGroupName gInfo + (SMDSnd, GroupChat gInfo _scopeInfo) -> Just $ "you #" <> viewGroupName gInfo + (SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> viewGroupName gInfo _ -> Nothing fwdItemId = "chat item id: " <> (T.pack . show $ aChatItemId fwdACI) _ -> [] @@ -766,19 +771,19 @@ viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, itemEd (maybe [] forwardedFrom itemForwarded) (directQuote chatDir) quotedItem - GroupChat g -> case chatDir of + GroupChat g scopeInfo -> case chatDir of CIGroupRcv m -> case content of CIRcvMsgContent mc | itemLive == Just True && not liveItems -> [] | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta _ -> [] where - from = if itemEdited then ttyFromGroupEdited g m else ttyFromGroup g m + from = if itemEdited then ttyFromGroupEdited g scopeInfo m else ttyFromGroup g scopeInfo m CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ viewSentMessage to context mc ts tz meta _ -> [] where - to = if itemEdited then ttyToGroupEdited g else ttyToGroup g + to = if itemEdited then ttyToGroupEdited g scopeInfo else ttyToGroup g scopeInfo where context = maybe @@ -804,10 +809,10 @@ viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem DirectChat c -> case (chatDir, deletedContent) of (CIDirectRcv, CIRcvMsgContent mc) -> viewReceivedMessage (ttyFromContactDeleted c deletedText_) [] mc ts tz meta _ -> prohibited - GroupChat g -> case ciMsgContent deletedContent of + GroupChat g scopeInfo -> case ciMsgContent deletedContent of Just mc -> let m = chatItemMember g ci - in viewReceivedMessage (ttyFromGroupDeleted g m deletedText_) [] mc ts tz meta + in viewReceivedMessage (ttyFromGroupDeleted g scopeInfo m deletedText_) [] mc ts tz meta _ -> prohibited _ -> prohibited where @@ -826,11 +831,11 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md where from = ttyFromContact c reactionMsg mc = quoteText mc $ if toMsgDirection md == MDSnd then ">>" else ">" - (GroupChat g, CIGroupRcv m) -> case ciMsgContent content of + (GroupChat g scopeInfo, CIGroupRcv m) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc _ -> [] where - from = ttyFromGroup g m + from = ttyFromGroup g scopeInfo m reactionMsg mc = quoteText mc . ttyQuotedMember . Just $ sentByMember' g itemDir (LocalChat _, CILocalRcv) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc @@ -934,7 +939,7 @@ viewContactNotFound cName suspectedMember = viewChatCleared :: AChatInfo -> [StyledString] viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"] - GroupChat gi -> [ttyGroup' gi <> ": all messages are removed locally ONLY"] + GroupChat gi _scopeInfo -> [ttyGroup' gi <> ": all messages are removed locally ONLY"] LocalChat _ -> ["notes: all messages are removed"] ContactRequest _ -> [] ContactConnection _ -> [] @@ -1084,6 +1089,7 @@ viewUserJoinedGroup g@GroupInfo {membership} = where pendingApproval_ = case memberStatus membership of GSMemPendingApproval -> ", pending approval" + GSMemPendingReview -> ", connecting to group moderators for admission to group" _ -> "" viewJoinedGroupMember :: GroupInfo -> GroupMember -> [StyledString] @@ -1092,7 +1098,34 @@ viewJoinedGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, member [ (ttyGroup' g <> ": " <> ttyMember m <> " connected and pending approval, ") <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") ] - _ -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "] + GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " connected and pending review"] + _ -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group"] + +viewMemberAccepted :: GroupInfo -> GroupMember -> [StyledString] +viewMemberAccepted g m@GroupMember {memberStatus} = case memberStatus of + GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted and pending review (will introduce moderators)"] + _ -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted"] + +viewMemberAcceptedByOther :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] +viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory} = case memberCategory of + GCInviteeMember -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted " <> ttyMember m <> " to the group (will introduce remaining members)"] + _ -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted " <> ttyMember m <> " to the group"] + +viewJoinedGroupMemberConnecting :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] +viewJoinedGroupMemberConnecting g@GroupInfo {groupId} host m@GroupMember {groupMemberId, memberStatus} = case memberStatus of + GSMemPendingReview -> + [ (ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting and pending review...), ") + <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") + ] + _ -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + +viewConnectedToGroupMember :: GroupInfo -> GroupMember -> [StyledString] +viewConnectedToGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, memberStatus} = case memberStatus of + GSMemPendingReview -> + [ (ttyGroup' g <> ": " <> connectedMember m <> " is connected and pending review, ") + <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") + ] + _ -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] viewReceivedGroupInvitation g c role = @@ -1164,6 +1197,11 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt | not (showMessages $ memberSettings m) = ["blocked"] | otherwise = [] +viewSupportMembers :: [GroupMember] -> [StyledString] +viewSupportMembers = map groupMember + where + groupMember m = memIncognito m <> ttyFullMember m <> ", id: " <> sShow (groupMemberId' m) + viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected ct userIncognitoProfile testView = case userIncognitoProfile of @@ -1709,7 +1747,7 @@ viewConnectionPlan = \case GLPConnectingProhibit (Just g) -> [grpOrBiz g <> " link: connecting to " <> grpOrBiz g <> " " <> ttyGroup' g] GLPKnown g -> [ grpOrBiz g <> " link: known " <> grpOrBiz g <> " " <> ttyGroup' g, - "use " <> ttyToGroup g <> highlight' "" <> " to send messages" + "use " <> ttyToGroup g Nothing <> highlight' "" <> " to send messages" ] where grpLink = ("group link: " <>) @@ -1838,7 +1876,7 @@ uploadingFile :: StyledString -> AChatItem -> [StyledString] uploadingFile status = \case AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectSnd} -> [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyContact c] - AChatItem _ _ (GroupChat g) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} -> + AChatItem _ _ (GroupChat g _scopeInfo) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupSnd} -> [status <> " uploading " <> fileTransferStr fileId fileName <> " for " <> ttyGroup' g] _ -> [status <> " uploading file"] @@ -2384,26 +2422,26 @@ ttyFullGroup :: GroupInfo -> StyledString ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullName}} = ttyGroup g <> optFullName g fullName -ttyFromGroup :: GroupInfo -> GroupMember -> StyledString -ttyFromGroup g m = ttyFromGroupAttention g m False +ttyFromGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString +ttyFromGroup g scopeInfo m = ttyFromGroupAttention g scopeInfo m False -ttyFromGroupAttention :: GroupInfo -> GroupMember -> Bool -> StyledString -ttyFromGroupAttention g m attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g m attention) +ttyFromGroupAttention :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> StyledString +ttyFromGroupAttention g scopeInfo m attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g scopeInfo m attention) -ttyFromGroupEdited :: GroupInfo -> GroupMember -> StyledString -ttyFromGroupEdited g m = membershipIncognito g <> ttyFrom (fromGroup_ g m <> "[edited] ") +ttyFromGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString +ttyFromGroupEdited g scopeInfo m = membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> "[edited] ") -ttyFromGroupDeleted :: GroupInfo -> GroupMember -> Maybe Text -> StyledString -ttyFromGroupDeleted g m deletedText_ = - membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) +ttyFromGroupDeleted :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Maybe Text -> StyledString +ttyFromGroupDeleted g scopeInfo m deletedText_ = + membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) -fromGroup_ :: GroupInfo -> GroupMember -> Text -fromGroup_ g m = fromGroupAttention_ g m False +fromGroup_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Text +fromGroup_ g scopeInfo m = fromGroupAttention_ g scopeInfo m False -fromGroupAttention_ :: GroupInfo -> GroupMember -> Bool -> Text -fromGroupAttention_ g m attention = +fromGroupAttention_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> Text +fromGroupAttention_ g scopeInfo m attention = let attn = if attention then "!" else "" - in "#" <> viewGroupName g <> " " <> viewMemberName m <> attn <> "> " + in "#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo <> viewMemberName m <> attn <> "> " ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow @@ -2411,11 +2449,18 @@ ttyFrom = styled $ colored Yellow ttyTo :: Text -> StyledString ttyTo = styled $ colored Cyan -ttyToGroup :: GroupInfo -> StyledString -ttyToGroup g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " ") +ttyToGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString +ttyToGroup g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo) -ttyToGroupEdited :: GroupInfo -> StyledString -ttyToGroupEdited g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " [edited] ") +ttyToGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString +ttyToGroupEdited g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> groupScopeInfoStr scopeInfo <> " [edited] ") + +groupScopeInfoStr :: Maybe GroupChatScopeInfo -> Text +groupScopeInfoStr = \case + Nothing -> "" + Just (GCSIMemberSupport {groupMember_}) -> case groupMember_ of + Nothing -> "(support) " + Just m -> "(support: " <> viewMemberName m <> ") " ttyFilePath :: FilePath -> StyledString ttyFilePath = plain diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c41c155697..33b6978ca3 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -468,6 +468,16 @@ testChatCfg4 cfg p1 p2 p3 p4 test = testChatN cfg testOpts [p1, p2, p3, p4] test test_ [tc1, tc2, tc3, tc4] = test tc1 tc2 tc3 tc4 test_ _ = error "expected 4 chat clients" +testChat5 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChat5 = testChatCfg5 testCfg + +testChatCfg5 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatCfg5 cfg p1 p2 p3 p4 p5 test = testChatN cfg testOpts [p1, p2, p3, p4, p5] test_ + where + test_ :: HasCallStack => [TestCC] -> IO () + test_ [tc1, tc2, tc3, tc4, tc5] = test tc1 tc2 tc3 tc4 tc5 + test_ _ = error "expected 5 chat clients" + concurrentlyN_ :: [IO a] -> IO () concurrentlyN_ = mapConcurrently_ id diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index dcd18a9818..0c0f0b0ddc 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -200,14 +200,14 @@ testPaginationAllChatTypes = ts7 <- iso8601Show <$> getCurrentTime - getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on")] + getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off")] getChats_ alice ("after=" <> ts2 <> " count=2") [(":3", ""), ("<@cath", "")] - getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", "Recent history: on"), (":3", "")] - getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", "")] + getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", "New member review: off"), (":3", "")] + getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", "")] getChats_ alice ("before=" <> ts4 <> " count=10") [(":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] getChats_ alice ("after=" <> ts7 <> " count=10") [] getChats_ alice ("before=" <> ts1 <> " count=10") [] @@ -219,11 +219,11 @@ testPaginationAllChatTypes = alice ##> "/_settings #1 {\"enableNtfs\":\"all\",\"favorite\":true}" alice <## "ok" - getChats_ alice queryFavorite [("#team", "Recent history: on"), ("@bob", "hey")] + getChats_ alice queryFavorite [("#team", "New member review: off"), ("@bob", "hey")] getChats_ alice ("before=" <> ts4 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "Recent history: on")] + getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "New member review: off")] getChats_ alice ("after=" <> ts1 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "Recent history: on")] + getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "New member review: off")] let queryUnread = "{\"type\": \"filters\", \"favorite\": false, \"unread\": true}" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 73a8735f63..e238b4b1bb 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -100,11 +100,12 @@ chatGroupTests = do it "group link member role" testGroupLinkMemberRole it "host profile received" testGroupLinkHostProfileReceived it "existing contact merged" testGroupLinkExistingContactMerged - describe "group links - join rejection" $ do - it "reject member joining via group link - blocked name" testGLinkRejectBlockedName - describe "group links - manual acceptance" $ do - it "manually accept member joining via group link" testGLinkManualAcceptMember - it "delete pending member" testGLinkDeletePendingMember + describe "group links - member screening" $ do + it "reject member - blocked name" testGLinkRejectBlockedName + it "accept member - only host approval" testGLinkApproveMember + it "accept member - only moderators review" testGLinkReviewMember + it "accept member - host approval, then moderators review" testGLinkApproveThenReviewMember + it "delete pending approval member" testGLinkDeletePendingApprovalMember describe "group link connection plan" $ do it "ok to connect; known group" testPlanGroupLinkKnown it "own group link" testPlanGroupLinkOwn @@ -191,8 +192,9 @@ chatGroupTests = do it "should send updated mentions in history" testGroupHistoryWithMentions describe "uniqueMsgMentions" testUniqueMsgMentions describe "updatedMentionNames" testUpdatedMentionNames - describe "group direct messages" $ do - it "should send group direct messages" testGroupDirectMessages + describe "group scoped messages" $ do + it "should send scoped messages to support (single moderator)" testScopedSupportSingleModerator + it "should send scoped messages to support (many moderators)" testScopedSupportManyModerators testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -311,7 +313,7 @@ testGroupShared alice bob cath checkMessages = do (bob <# "#team alice> hello") (cath "#team hello" - cath <## "you are no longer a member of the group" + cath <## "bad chat command: not current member" -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" @@ -385,7 +387,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n) -- All messages are unread for bob, should return area around unread - bob #$> ("/_get chat #1 initial=2", chat, [(0, "Recent history: on"), (0, "connected"), (0, "1"), (0, "2"), (0, "3")]) + bob #$> ("/_get chat #1 initial=2", chat, [(0, "New member review: off"), (0, "connected"), (0, "1"), (0, "2"), (0, "3")]) -- Read next 2 items let itemIds = intercalate "," $ map groupItemId [1 .. 2] @@ -571,7 +573,7 @@ testGroup2 = ] dan <##> alice -- show last messages - alice ##> "/t #club 18" + alice ##> "/t #club 19" alice -- these strings are expected in any order because of sorting by time and rounding of time for sent <##? ( map (ConsoleString . ("#club " <> )) groupFeatureStrs @@ -648,7 +650,7 @@ testGroup2 = (dan "#club how is it going?" - dan <## "you are no longer a member of the group" + dan <## "bad chat command: not current member" dan ##> "/d #club" dan <## "#club: you deleted the group" dan <##> alice @@ -670,7 +672,7 @@ testGroup2 = (alice <# "#club cath> hey") (bob "#club how is it going?" - bob <## "you are no longer a member of the group" + bob <## "bad chat command: not current member" bob ##> "/d #club" bob <## "#club: you deleted the group" bob <##> alice @@ -695,7 +697,7 @@ testGroupDelete = bob ##> "/d #team" bob <## "#team: you deleted the group" cath ##> "#team hi" - cath <## "you are no longer a member of the group" + cath <## "bad chat command: not current member" cath ##> "/d #team" cath <## "#team: you deleted the group" alice <##> bob @@ -1569,6 +1571,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "SimpleX links: on" alice <## "Member reports: on" alice <## "Recent history: on" + alice <## "New member review: off" bobAddedDan :: HasCallStack => TestCC -> IO () bobAddedDan cc = do cc <## "#team: bob added dan (Daniel) to the group (connecting...)" @@ -1608,7 +1611,7 @@ testGroupModerate = testGroupModerateOwn :: HasCallStack => TestParams -> IO () testGroupModerateOwn = - withTestOutput $ testChat2 aliceProfile bobProfile $ + testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob -- disableFullDeletion2 "team" alice bob @@ -2957,14 +2960,18 @@ testGLinkRejectBlockedName = DB.query_ db "SELECT count(1) FROM group_members" :: IO [[Int]] memCount `shouldBe` [[1]] + -- rejected member can't send messages to group + bob ##> "#team hello" + bob <## "bad chat command: not current member" + bob ##> ("/c " <> gLink) bob <## "group link: known group #team" bob <## "use #team to send messages" where cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Left GRRBlockedName)}} -testGLinkManualAcceptMember :: HasCallStack => TestParams -> IO () -testGLinkManualAcceptMember = +testGLinkApproveMember :: HasCallStack => TestParams -> IO () +testGLinkApproveMember = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup2 "team" alice bob @@ -2981,26 +2988,30 @@ testGLinkManualAcceptMember = cath <## "#team: you joined the group, pending approval" ] - -- pending approval member doesn't see messages sent in group + -- pending member doesn't see messages sent in group alice #> "#team hi group" bob <# "#team alice> hi group" bob #> "#team hey" alice <# "#team bob> hey" - -- pending approval member and host can send messages to each other - alice ##> "/_send #1 @3 text send me proofs" - alice <# "#team send me proofs" - cath <# "#team alice> send me proofs" + -- pending member can't send messages to group + cath ##> "#team hello" + cath <## "bad chat command: not current member" - cath ##> "/_send #1 @1 text proofs" - cath <# "#team proofs" - alice <# "#team cath> proofs" + -- pending member and host can send messages to each other + alice ##> "/_send #1(_support:3) text send me proofs" + alice <# "#team (support: cath) send me proofs" + cath <# "#team (support) alice> send me proofs" + + cath ##> "/_send #1(_support) text proofs" + cath <# "#team (support) proofs" + alice <# "#team (support: cath) cath> proofs" -- accept member alice ##> "/_accept member #1 3 member" concurrentlyN_ - [ alice <## "#team: cath joined the group", + [ alice <## "#team: cath accepted", cath <### [ "#team: you joined the group", WithTime "#team alice> hi group [>>]", @@ -3023,8 +3034,301 @@ testGLinkManualAcceptMember = where cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPending, GRObserver))}} -testGLinkDeletePendingMember :: HasCallStack => TestParams -> IO () -testGLinkDeletePendingMember = +testGLinkReviewMember :: HasCallStack => TestParams -> IO () +testGLinkReviewMember = + testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ + \alice bob cath dan eve -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRModerator) (dan, GRModerator) + + alice ##> "/set new member review #team on" + alice <## "updated group preferences:" + alice <## "New member review: on" + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "updated group preferences:" + bob <## "New member review: on", + do + cath <## "alice updated group #team:" + cath <## "updated group preferences:" + cath <## "New member review: on", + do + dan <## "alice updated group #team:" + dan <## "updated group preferences:" + dan <## "New member review: on" + ] + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + alice <## "eve (Eve): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: eve connected and pending review", + eve + <### [ "#team: joining the group...", + "#team: you joined the group, connecting to group moderators for admission to group", + "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + do + cath <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + cath <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member", + do + dan <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + dan <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member" + ] + + -- pending member doesn't see messages sent in group + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + cath #> "#team 3" + [alice, bob, dan] *<# "#team cath> 3" + + dan #> "#team 4" + [alice, bob, cath] *<# "#team dan> 4" + + (eve "#team hello" + eve <## "bad chat command: not current member" + + -- pending member and moderators can send messages to each other + alice ##> "/_send #1(_support:5) text 5" + alice <# "#team (support: eve) 5" + [cath, dan] *<# "#team (support: eve) alice> 5" + eve <# "#team (support) alice> 5" + + cath ##> "/_send #1(_support:5) text 6" + cath <# "#team (support: eve) 6" + [alice, dan] *<# "#team (support: eve) cath> 6" + eve <# "#team (support) cath> 6" + + dan ##> "/_send #1(_support:5) text 7" + dan <# "#team (support: eve) 7" + [alice, cath] *<# "#team (support: eve) dan> 7" + eve <# "#team (support) dan> 7" + + eve ##> "/_send #1(_support) text 8" + eve <# "#team (support) 8" + [alice, cath, dan] *<# "#team (support: eve) eve> 8" + + (bob "/_accept member #1 5 member" + alice <## "bad chat command: member should be pending approval and invitee, or pending review and not invitee" + + -- accept member + dan ##> "/_accept member #1 5 member" + concurrentlyN_ + [ dan <## "#team: eve accepted", + alice <## "#team: dan accepted eve to the group (will introduce remaining members)", + cath <## "#team: dan accepted eve to the group", + eve + <### [ "#team: you joined the group", + WithTime "#team alice> 1 [>>]", + WithTime "#team bob> 2 [>>]", + WithTime "#team cath> 3 [>>]", + WithTime "#team dan> 4 [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added eve (Eve) to the group (connecting...)" + bob <## "#team: new member eve is connected" + ] + + alice #> "#team 9" + [bob, cath, dan, eve] *<# "#team alice> 9" + + bob #> "#team 10" + [alice, cath, dan, eve] *<# "#team bob> 10" + + cath #> "#team 11" + [alice, bob, dan, eve] *<# "#team cath> 11" + + dan #> "#team 12" + [alice, bob, cath, eve] *<# "#team dan> 12" + + eve #> "#team 13" + [alice, bob, cath, dan] *<# "#team eve> 13" + +testGLinkApproveThenReviewMember :: HasCallStack => TestParams -> IO () +testGLinkApproveThenReviewMember = + testChatCfg5 cfg aliceProfile bobProfile cathProfile danProfile eveProfile $ + \alice bob cath dan eve -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRModerator) (dan, GRModerator) + + alice ##> "/set new member review #team on" + alice <## "updated group preferences:" + alice <## "New member review: on" + concurrentlyN_ + [ do + bob <## "alice updated group #team:" + bob <## "updated group preferences:" + bob <## "New member review: on", + do + cath <## "alice updated group #team:" + cath <## "updated group preferences:" + cath <## "New member review: on", + do + dan <## "alice updated group #team:" + dan <## "updated group preferences:" + dan <## "New member review: on" + ] + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + alice <## "eve (Eve): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: eve connected and pending approval, use /_accept member #1 5 to accept member", + do + eve <## "#team: joining the group..." + eve <## "#team: you joined the group, pending approval" + ] + + -- pending member doesn't see messages sent in group + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + cath #> "#team 3" + [alice, bob, dan] *<# "#team cath> 3" + + dan #> "#team 4" + [alice, bob, cath] *<# "#team dan> 4" + + (eve "#team hello" + eve <## "bad chat command: not current member" + + -- pending member and host can send messages to each other + alice ##> "/_send #1(_support:5) text 5" + alice <# "#team (support: eve) 5" + eve <# "#team (support) alice> 5" + + eve ##> "/_send #1(_support) text 6" + eve <# "#team (support) 6" + alice <# "#team (support: eve) eve> 6" + + (bob "/_accept member #1 5 member" + concurrentlyN_ + [ alice <## "#team: eve accepted and pending review (will introduce moderators)", + eve + <### [ "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + do + cath <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + cath <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member", + do + dan <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 to accept member" + dan <## "#team: new member eve is connected and pending review, use /_accept member #1 5 to accept member" + ] + + -- pending member doesn't see messages sent in group + alice #> "#team 7" + [bob, cath, dan] *<# "#team alice> 7" + + bob #> "#team 8" + [alice, cath, dan] *<# "#team bob> 8" + + cath #> "#team 9" + [alice, bob, dan] *<# "#team cath> 9" + + dan #> "#team 10" + [alice, bob, cath] *<# "#team dan> 10" + + (eve "#team hello" + eve <## "bad chat command: not current member" + + -- pending member and moderators can send messages to each other + alice ##> "/_send #1(_support:5) text 11" + alice <# "#team (support: eve) 11" + [cath, dan] *<# "#team (support: eve) alice> 11" + eve <# "#team (support) alice> 11" + + cath ##> "/_send #1(_support:5) text 12" + cath <# "#team (support: eve) 12" + [alice, dan] *<# "#team (support: eve) cath> 12" + eve <# "#team (support) cath> 12" + + dan ##> "/_send #1(_support:5) text 13" + dan <# "#team (support: eve) 13" + [alice, cath] *<# "#team (support: eve) dan> 13" + eve <# "#team (support) dan> 13" + + eve ##> "/_send #1(_support) text 14" + eve <# "#team (support) 14" + [alice, cath, dan] *<# "#team (support: eve) eve> 14" + + (bob "/_accept member #1 5 member" + alice <## "bad chat command: member should be pending approval and invitee, or pending review and not invitee" + + -- accept member + dan ##> "/_accept member #1 5 member" + concurrentlyN_ + [ dan <## "#team: eve accepted", + alice <## "#team: dan accepted eve to the group (will introduce remaining members)", + cath <## "#team: dan accepted eve to the group", + eve + <### [ "#team: you joined the group", + WithTime "#team alice> 1 [>>]", + WithTime "#team bob> 2 [>>]", + WithTime "#team cath> 3 [>>]", + WithTime "#team dan> 4 [>>]", + WithTime "#team alice> 7 [>>]", + WithTime "#team bob> 8 [>>]", + WithTime "#team cath> 9 [>>]", + WithTime "#team dan> 10 [>>]", + "#team: member bob (Bob) is connected" + ], + do + bob <## "#team: alice added eve (Eve) to the group (connecting...)" + bob <## "#team: new member eve is connected" + ] + + alice #> "#team 15" + [bob, cath, dan, eve] *<# "#team alice> 15" + + bob #> "#team 16" + [alice, cath, dan, eve] *<# "#team bob> 16" + + cath #> "#team 17" + [alice, bob, dan, eve] *<# "#team cath> 17" + + dan #> "#team 18" + [alice, bob, cath, eve] *<# "#team dan> 18" + + eve #> "#team 19" + [alice, bob, cath, dan] *<# "#team eve> 19" + where + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPending, GRObserver))}} + +testGLinkDeletePendingApprovalMember :: HasCallStack => TestParams -> IO () +testGLinkDeletePendingApprovalMember = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup2 "team" alice bob @@ -4171,8 +4475,8 @@ testRecreateMemberContactManyGroups = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - createGroup2' "team" alice bob False - createGroup2' "club" alice bob False + createGroup2' "team" alice (bob, GRAdmin) False + createGroup2' "club" alice (bob, GRAdmin) False -- alice can message bob via team and via club alice ##> "@#team bob 1" @@ -4291,14 +4595,14 @@ testGroupMsgForwardReport = cath <## "#team: alice changed your role from admin to member" ] cath ##> "/report #team content hi there" - cath <# "#team > bob hi there" + cath <# "#team (support) > bob hi there" cath <## " report content" concurrentlyN_ [ do - alice <# "#team cath> > bob hi there" + alice <# "#team (support: cath) cath> > bob hi there" alice <## " report content", do - bob <# "#team cath!> > bob hi there [>>]" + bob <# "#team (support: cath) cath!> > bob hi there [>>]" bob <## " report content [>>]" ] @@ -4310,11 +4614,11 @@ testGroupMsgForwardReport = ] cath ##> "/report #team content hi there" - cath <# "#team > bob hi there" + cath <# "#team (support) > bob hi there" cath <## " report content" concurrentlyN_ [ do - alice <# "#team cath> > bob hi there" + alice <# "#team (support: cath) cath> > bob hi there" alice <## " report content", (bob do createGroup2 "team" alice bob - createGroup2' "club" alice bob False + createGroup2' "club" alice (bob, GRAdmin) False alice ##> "/d bob" alice <## "bob: contact is deleted" @@ -6323,14 +6627,14 @@ testGroupMemberReports = dan <# "#jokes cath> inappropriate joke" ] dan ##> "/report #jokes content inappropriate joke" - dan <# "#jokes > cath inappropriate joke" + dan <# "#jokes (support) > cath inappropriate joke" dan <## " report content" concurrentlyN_ [ do - alice <# "#jokes dan> > cath inappropriate joke" + alice <# "#jokes (support: dan) dan> > cath inappropriate joke" alice <## " report content", do - bob <# "#jokes dan> > cath inappropriate joke" + bob <# "#jokes (support: dan) dan> > cath inappropriate joke" bob <## " report content", (cath ok joke" ] dan ##> "/report #jokes content ok joke" - dan <# "#jokes > cath ok joke" + dan <# "#jokes (support) > cath ok joke" dan <## " report content" dan ##> "/report #jokes spam ok joke" - dan <# "#jokes > cath ok joke" + dan <# "#jokes (support) > cath ok joke" dan <## " report spam" concurrentlyN_ [ do - alice <# "#jokes dan> > cath ok joke" + alice <# "#jokes (support: dan) dan> > cath ok joke" alice <## " report content" - alice <# "#jokes dan> > cath ok joke" + alice <# "#jokes (support: dan) dan> > cath ok joke" alice <## " report spam", do - bob <# "#jokes dan> > cath ok joke" + bob <# "#jokes (support: dan) dan> > cath ok joke" bob <## " report content" - bob <# "#jokes dan> > cath ok joke" + bob <# "#jokes (support: dan) dan> > cath ok joke" bob <## " report spam", (cath ok joke 2" ] dan ##> "/report #jokes content ok joke 2" - dan <# "#jokes > cath ok joke 2" + dan <# "#jokes (support) > cath ok joke 2" dan <## " report content" concurrentlyN_ [ do - alice <# "#jokes dan> > cath ok joke 2" + alice <# "#jokes (support: dan) dan> > cath ok joke 2" alice <## " report content", do - bob <# "#jokes dan> > cath ok joke 2" + bob <# "#jokes (support: dan) dan> > cath ok joke 2" bob <## " report content", (cath getTermLine alice alice ##> ("/_delete reports #1 " <> show i <> " broadcast") alice <## "message marked deleted by you" - bob <# "#jokes dan> [marked deleted by alice] report content" + bob <# "#jokes (support: dan) dan> [marked deleted by alice] report content" alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by alice]")]) dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content")]) @@ -6610,10 +6914,10 @@ testUpdatedMentionNames = do where ciMentionMember name = CIMentionMember {groupMemberId = 1, displayName = name, localAlias = Nothing, memberRole = GRMember} -testGroupDirectMessages :: HasCallStack => TestParams -> IO () -testGroupDirectMessages = +testScopedSupportSingleModerator :: HasCallStack => TestParams -> IO () +testScopedSupportSingleModerator = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) alice #> "#team 1" [bob, cath] *<# "#team alice> 1" @@ -6621,25 +6925,67 @@ testGroupDirectMessages = bob #> "#team 2" [alice, cath] *<# "#team bob> 2" - void $ withCCTransaction alice $ \db -> - DB.execute_ db "UPDATE group_members SET member_status='pending_approval' WHERE group_member_id = 2" + alice ##> "/_send #1(_support:2) text 3" + alice <# "#team (support: bob) 3" + bob <# "#team (support) alice> 3" - alice ##> "/_send #1 @2 text 3" - alice <# "#team 3" - bob <# "#team alice> 3" + bob ##> "/_send #1(_support) text 4" + bob <# "#team (support) 4" + alice <# "#team (support: bob) bob> 4" - void $ withCCTransaction bob $ \db -> - DB.execute_ db "UPDATE group_members SET member_status='pending_approval' WHERE group_member_id = 1" + cath ##> "/_send #1(_support:3) text 5" + cath <## "#team: you have insufficient permissions for this action, the required role is moderator" - bob ##> "/_send #1 @1 text 4" - bob <# "#team 4" - alice <# "#team bob> 4" +testScopedSupportManyModerators :: HasCallStack => TestParams -> IO () +testScopedSupportManyModerators = + testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) - -- GSMemPendingApproval members don't receive messages sent to group. - -- Though in test we got here synthetically, in reality this status - -- means they are not yet part of group (not memberCurrent). - alice #> "#team 5" - cath <# "#team alice> 5" + threadDelay 1000000 - bob #> "#team 6" - cath <# "#team bob> 6" + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + threadDelay 1000000 + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + threadDelay 1000000 + + alice ##> "/_send #1(_support:2) text 3" + alice <# "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + dan <# "#team (support: bob) alice> 3" + + threadDelay 1000000 + + bob ##> "/_send #1(_support) text 4" + bob <# "#team (support) 4" + [alice, dan] *<# "#team (support: bob) bob> 4" + + threadDelay 1000000 + + dan ##> "/_send #1(_support:3) text 5" + dan <# "#team (support: bob) 5" + alice <# "#team (support: bob) dan> 5" + bob <# "#team (support) dan> 5" + + alice #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (1, "1"), (0, "2")]) + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(1, "3"), (0, "4"), (0, "5")]) + bob #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (1, "2")]) + bob #$> ("/_get chat #1(_support) count=100", chat, [(0, "3"), (1, "4"), (0, "5")]) + dan #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (0, "2")]) + dan #$> ("/_get chat #1(_support:3) count=100", chat, [(0, "3"), (0, "4"), (1, "5")]) + cath #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (0, "2")]) + cath ##> "/_get chat #1(_support:3) count=100" + cath <## "chat db error: SEInternalError {message = \"no support chat\"}" + + alice ##> "/member support chats #team" + alice <## "bob (Bob), id: 2" + dan ##> "/member support chats #team" + dan <## "bob (Bob), id: 3" + bob ##> "/member support chats #team" + bob <## "#team: you have insufficient permissions for this action, the required role is moderator" + cath ##> "/member support chats #team" + cath <## "#team: you have insufficient permissions for this action, the required role is moderator" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 72d502392e..bc0b0685df 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -741,7 +741,7 @@ testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice (biz <# "#bob bob_1> hey there") testBusinessUpdateProfiles :: HasCallStack => TestParams -> IO () -testBusinessUpdateProfiles = withTestOutput $ testChat4 businessProfile aliceProfile bobProfile cathProfile $ +testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile cathProfile $ \biz alice bob cath -> do biz ##> "/ad" cLink <- getContactLink biz True @@ -1625,7 +1625,7 @@ testJoinGroupIncognito = (cath "#secret_club hello" - cath <## "you are no longer a member of the group" + cath <## "bad chat command: not current member" testCantInviteContactIncognito :: HasCallStack => TestParams -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 9a3b560b29..2ca5a0f5b0 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -71,6 +71,9 @@ cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Not danProfile :: Profile danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} +eveProfile :: Profile +eveProfile = Profile {displayName = "eve", fullName = "Eve", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} + businessProfile :: Profile businessProfile = Profile {displayName = "biz", fullName = "Biz Inc", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} @@ -307,7 +310,8 @@ groupFeatures'' dir = ((dir, "Files and media: on"), Nothing, Nothing), ((dir, "SimpleX links: on"), Nothing, Nothing), ((dir, "Member reports: on"), Nothing, Nothing), - ((dir, "Recent history: on"), Nothing, Nothing) + ((dir, "Recent history: on"), Nothing, Nothing), + ((dir, "New member review: off"), Nothing, Nothing) ] itemId :: Int -> String @@ -630,16 +634,16 @@ showName (TestCC ChatController {currentUser} _ _ _ _ _) = do pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName createGroup2 :: HasCallStack => String -> TestCC -> TestCC -> IO () -createGroup2 gName cc1 cc2 = createGroup2' gName cc1 cc2 True +createGroup2 gName cc1 cc2 = createGroup2' gName cc1 (cc2, GRAdmin) True -createGroup2' :: HasCallStack => String -> TestCC -> TestCC -> Bool -> IO () -createGroup2' gName cc1 cc2 doConnectUsers = do +createGroup2' :: HasCallStack => String -> TestCC -> (TestCC, GroupMemberRole) -> Bool -> IO () +createGroup2' gName cc1 (cc2, role2) doConnectUsers = do when doConnectUsers $ connectUsers cc1 cc2 name2 <- userName cc2 cc1 ##> ("/g " <> gName) cc1 <## ("group #" <> gName <> " is created") cc1 <## ("to add members use /a " <> gName <> " or /create link #" <> gName) - addMember gName cc1 cc2 GRAdmin + addMember gName cc1 cc2 role2 cc2 ##> ("/j " <> gName) concurrently_ (cc1 <## ("#" <> gName <> ": " <> name2 <> " joined the group")) @@ -657,13 +661,17 @@ disableFullDeletion2 gName cc1 cc2 = do createGroup3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () createGroup3 gName cc1 cc2 cc3 = do - createGroup2 gName cc1 cc2 + createGroup3' gName cc1 (cc2, GRAdmin) (cc3, GRAdmin) + +createGroup3' :: HasCallStack => String -> TestCC -> (TestCC, GroupMemberRole) -> (TestCC, GroupMemberRole) -> IO () +createGroup3' gName cc1 (cc2, role2) (cc3, role3) = do + createGroup2' gName cc1 (cc2, role2) True connectUsers cc1 cc3 name1 <- userName cc1 name3 <- userName cc3 sName2 <- showName cc2 sName3 <- showName cc3 - addMember gName cc1 cc3 GRAdmin + addMember gName cc1 cc3 role3 cc3 ##> ("/j " <> gName) concurrentlyN_ [ cc1 <## ("#" <> gName <> ": " <> name3 <> " joined the group"), @@ -675,6 +683,31 @@ createGroup3 gName cc1 cc2 cc3 = do cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] +createGroup4 :: HasCallStack => String -> TestCC -> (TestCC, GroupMemberRole) -> (TestCC, GroupMemberRole) -> (TestCC, GroupMemberRole) -> IO () +createGroup4 gName cc1 (cc2, role2) (cc3, role3) (cc4, role4) = do + createGroup3' gName cc1 (cc2, role2) (cc3, role3) + connectUsers cc1 cc4 + name1 <- userName cc1 + name4 <- userName cc4 + sName2 <- showName cc2 + sName3 <- showName cc3 + sName4 <- showName cc4 + addMember gName cc1 cc4 role4 + cc4 ##> ("/j " <> gName) + concurrentlyN_ + [ cc1 <## "#team: dan joined the group", + do + cc4 <## ("#" <> gName <> ": you joined the group") + cc4 <## ("#" <> gName <> ": member " <> sName2 <> " is connected") + cc4 <## ("#" <> gName <> ": member " <> sName3 <> " is connected"), + do + cc2 <## ("#" <> gName <> ": " <> name1 <> " added " <> sName4 <> " to the group (connecting...)") + cc2 <## ("#" <> gName <> ": new member " <> name4 <> " is connected"), + do + cc3 <## ("#" <> gName <> ": " <> name1 <> " added " <> sName4 <> " to the group (connecting...)") + cc3 <## ("#" <> gName <> ": new member " <> name4 <> " is connected") + ] + disableFullDeletion3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () disableFullDeletion3 gName cc1 cc2 cc3 = do disableFullDeletion2 gName cc1 cc2 @@ -686,7 +719,7 @@ disableFullDeletion3 gName cc1 cc2 cc3 = do create2Groups3 :: HasCallStack => String -> String -> TestCC -> TestCC -> TestCC -> IO () create2Groups3 gName1 gName2 cc1 cc2 cc3 = do createGroup3 gName1 cc1 cc2 cc3 - createGroup2' gName2 cc1 cc2 False + createGroup2' gName2 cc1 (cc2, GRAdmin) False name1 <- userName cc1 name3 <- userName cc3 addMember gName2 cc1 cc3 GRAdmin diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 5c672b2858..ea8d54a0bd 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -101,7 +101,7 @@ testChatPreferences :: Maybe Preferences testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}} 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} +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, newMemberReview = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), contactLink = Nothing, preferences = testChatPreferences} @@ -116,10 +116,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)) it "x.msg.new simple text - timed message TTL" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing)) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) it "x.msg.new simple text - live message" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True))) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) it "x.msg.new simple link" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing)) @@ -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 (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-14\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-15\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -146,22 +146,22 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing Nothing))) it "x.msg.new quote - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True)))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True) Nothing))) it "x.msg.new forward" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing)) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) it "x.msg.new forward - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True))) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) it "x.msg.new simple text with file" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.update" $ "{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing + #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing @@ -247,15 +247,15 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ "{\"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} + #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} 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-14\",\"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} + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-15\",\"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} 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} 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-14\",\"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-15\",\"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} 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\"}}}}}}" @@ -270,7 +270,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-3%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-3%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} 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-3%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-14\",\"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-3%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-15\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From 9654e193afb9e42747cb62d9286b8183e3d4826f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:55:32 +0000 Subject: [PATCH 02/72] core: member admission (#5798) --- .../src/Directory/Service.hs | 2 +- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 18 +++++-- src/Simplex/Chat/Library/Internal.hs | 6 +-- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 21 ++++---- .../Migrations/M20250310_group_scope.hs | 4 ++ .../SQLite/Migrations/chat_query_plans.txt | 30 +++++------ .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/Shared.hs | 8 +-- src/Simplex/Chat/Types.hs | 34 +++++++++++-- src/Simplex/Chat/Types/Preferences.hs | 51 ++++--------------- src/Simplex/Chat/View.hs | 9 ++-- tests/Bots/DirectoryTests.hs | 16 +++--- tests/ChatTests/ChatList.hs | 18 +++---- tests/ChatTests/Groups.hs | 31 ++++------- tests/ChatTests/Utils.hs | 3 +- tests/ProtocolTests.hs | 4 +- 18 files changed, 131 insertions(+), 130 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 825212c888..d10656c750 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -473,7 +473,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ gmId = groupMemberId' m sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case - CRJoinedGroupMember {member} -> do + CRMemberAccepted {member} -> do atomically $ TM.delete gmId $ pendingCaptchas env if memberStatus member == GSMemPendingReview then logInfo $ "Member " <> viewName displayName <> " accepted and pending review, group " <> tshow groupId <> ":" <> viewGroupName g diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c187dc10ba..1ea2991c24 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -524,6 +524,7 @@ data ChatCommand | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) | SetGroupFeature AGroupFeatureNoRole GroupName GroupFeatureEnabled | SetGroupFeatureRole AGroupFeatureRole GroupName GroupFeatureEnabled (Maybe GroupMemberRole) + | SetGroupMemberAdmissionReview GroupName (Maybe MemberCriteria) | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index cf56fafd14..4bcfcdd1b0 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2044,14 +2044,16 @@ processChatCommand' vr = \case assertUserGroupRole gInfo GRModerator case memberStatus m of GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve + let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo case memberConn m of - Just mConn - | groupFeatureAllowed SGFNewMemberReview gInfo -> do + Just mConn -> + case memberAdmission >>= review of + Just MCAll -> do introduceToModerators vr user gInfo m withFastStore' $ \db -> updateGroupMemberStatus db userId m GSMemPendingReview let m' = m {memberStatus = GSMemPendingReview} pure $ CRMemberAccepted user gInfo m' - | otherwise -> do + Nothing -> do let msg = XGrpLinkAcpt role (Just $ memberId' m) void $ sendDirectMemberMessage mConn msg groupId introduceToRemaining vr user gInfo m {memberRole = role} @@ -2556,6 +2558,11 @@ processChatCommand' vr = \case SetGroupFeatureRole (AGFR f) gName enabled role -> updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreferenceRole f enabled role $ groupPreferences p} + SetGroupMemberAdmissionReview gName reviewAdmissionApplication -> + updateGroupProfileByName gName $ \p@GroupProfile {memberAdmission} -> + case memberAdmission of + Nothing -> p {memberAdmission = Just (emptyGroupMemberAdmission :: GroupMemberAdmission) {review = reviewAdmissionApplication}} + Just ma -> p {memberAdmission = Just (ma :: GroupMemberAdmission) {review = reviewAdmissionApplication}} SetUserTimedMessages onOff -> withUser $ \user@User {profile} -> do let allowed = if onOff then FAYes else FANo pref = TimedMessagesPreference allowed Nothing @@ -4187,7 +4194,6 @@ chatCommandP = "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayNameP <*> _strP <*> optional memberRole), "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayNameP <*> (A.space *> strP)), - "/set new member review #" *> (SetGroupFeature (AGFNR SGFNewMemberReview) <$> displayNameP <*> (A.space *> strP)), "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayNameP <*> (A.space *> strP)), "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayNameP <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), @@ -4200,6 +4206,7 @@ chatCommandP = "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), "/set reports #" *> (SetGroupFeature (AGFNR SGFReports) <$> displayNameP <*> _strP), "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), + "/set admission review #" *> (SetGroupMemberAdmissionReview <$> displayNameP <*> (A.space *> memberCriteriaP)), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, @@ -4299,7 +4306,8 @@ chatCommandP = { directMessages = Just DirectMessagesGroupPreference {enable = FEOn, role = Nothing}, history = Just HistoryGroupPreference {enable = FEOn} } - pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences} + pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences, memberAdmission = Nothing} + memberCriteriaP = ("all" $> Just MCAll) <|> ("off" $> Nothing) fullNameP = A.space *> textP <|> pure "" textP = safeDecodeUtf8 <$> A.takeByteString pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f41d67d37a..5ef97442da 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -859,13 +859,13 @@ acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = Agen acceptGroupJoinRequestAsync :: User -> GroupInfo -> UserContactRequest -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember acceptGroupJoinRequestAsync user - gInfo@GroupInfo {groupProfile, fullGroupPreferences, membership, businessChat} + gInfo@GroupInfo {groupProfile, membership, businessChat} ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} gAccepted gLinkMemRole incognitoProfile = do gVar <- asks random - let initialStatus = acceptanceToStatus fullGroupPreferences gAccepted + let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted (groupMemberId, memberId) <- withStore $ \db -> do liftIO $ deleteContactRequestRec db user ucr createJoiningMember db gVar user gInfo ucr gLinkMemRole initialStatus @@ -957,7 +957,7 @@ acceptBusinessJoinRequestAsync where businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, image} groupPreferences = - GroupProfile {displayName, fullName, description = Nothing, image, groupPreferences = Just groupPreferences} + GroupProfile {displayName, fullName, description = Nothing, image, groupPreferences = Just groupPreferences, memberAdmission = Nothing} profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Bool -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 8d2b1326e5..b63ea91126 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -134,7 +134,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 36169c1a06..054fc24519 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -278,7 +278,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} @@ -529,8 +529,7 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { 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 let fromMemberProfile = profileFromName fromMemberName - fullGroupPreferences = mergeGroupPreferences $ groupPreferences groupProfile - initialStatus = maybe GSMemAccepted (acceptanceToStatus fullGroupPreferences) accepted + initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) @@ -769,7 +768,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, @@ -1569,7 +1568,7 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} @@ -1627,7 +1626,7 @@ getViaGroupContact db vr user@User {userId} GroupMember {groupMemberId} = do maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs @@ -1645,14 +1644,14 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, db [sql| UPDATE group_profiles - SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, updated_at = ? + SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ? ) |] - (newName, fullName, description, image, groupPreferences, currentTs, userId, groupId) + (newName, fullName, description, image, groupPreferences, memberAdmission, currentTs, userId, groupId) updateGroup_ ldn currentTs = do DB.execute db @@ -1690,14 +1689,14 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName DB.query db [sql| - SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences + SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) - toGroupProfile (displayName, fullName, description, image, groupPreferences) = - GroupProfile {displayName, fullName, description, image, groupPreferences} + toGroupProfile (displayName, fullName, description, image, groupPreferences, memberAdmission) = + GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs index 11db5f7084..fa48bb8660 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs @@ -16,6 +16,8 @@ import Database.SQLite.Simple.QQ (sql) m20250310_group_scope :: Query m20250310_group_scope = [sql| +ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; + ALTER TABLE groups ADD COLUMN mods_support_chat_ts TEXT; ALTER TABLE groups ADD COLUMN mods_support_chat_unanswered INTEGER; @@ -51,4 +53,6 @@ ALTER TABLE group_members DROP COLUMN support_chat_unanswered; ALTER TABLE groups DROP COLUMN mods_support_chat_ts; ALTER TABLE groups DROP COLUMN mods_support_chat_unanswered; + +ALTER TABLE group_profiles DROP COLUMN member_admission; |] 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 195617c38d..3f8f114a4a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -35,7 +35,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} @@ -529,7 +529,7 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences + SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? @@ -793,7 +793,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} @@ -839,7 +839,7 @@ SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group Query: SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, @@ -1225,7 +1225,7 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_profiles - SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, updated_at = ? + SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups @@ -2956,6 +2956,14 @@ Query: Plan: SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) +Query: + SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + FROM user_contact_links + WHERE user_id = ? AND user_contact_link_id = ? + +Plan: +SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, @@ -4464,7 +4472,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership @@ -4487,7 +4495,7 @@ Query: SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership @@ -4682,14 +4690,6 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) -Query: - SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role - FROM user_contact_links - WHERE user_id = ? - AND user_contact_link_id = ? -Plan: -SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 22cb883a14..3fde7ce08e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -108,7 +108,8 @@ CREATE TABLE group_profiles( image TEXT, user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE, preferences TEXT, - description TEXT NULL + description TEXT NULL, + member_admission TEXT ); CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index adb20d1d33..093382f3ee 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -577,16 +577,16 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. (Maybe UTCTime, Maybe BoolInt) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. (Maybe UTCTime, Maybe BoolInt) :. GroupMemberRow type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Maybe BoolInt) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. (modsSupportChatTs_, modsSupportChatUnanswered_) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. (modsSupportChatTs_, modsSupportChatUnanswered_) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences - groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} + groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow modsSupportChat = case (modsSupportChatTs_, modsSupportChatUnanswered_) of (Just modsChatTs, unanswered_) -> Just GroupSupportChat {chatTs = modsChatTs, unanswered = maybe False unBI unanswered_} @@ -616,7 +616,7 @@ groupInfoQuery = SELECT -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7d09c54210..6ce902a48d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -57,7 +57,7 @@ import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) -import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal #if defined(dbPostgres) @@ -621,10 +621,24 @@ data GroupProfile = GroupProfile fullName :: Text, description :: Maybe Text, image :: Maybe ImageData, - groupPreferences :: Maybe GroupPreferences + groupPreferences :: Maybe GroupPreferences, + memberAdmission :: Maybe GroupMemberAdmission } deriving (Eq, Show) +data GroupMemberAdmission = GroupMemberAdmission + { -- names :: Maybe MemberCriteria, + -- captcha :: Maybe MemberCriteria, + review :: Maybe MemberCriteria + } + deriving (Eq, Show) + +data MemberCriteria = MCAll + deriving (Eq, Show) + +emptyGroupMemberAdmission :: GroupMemberAdmission +emptyGroupMemberAdmission = GroupMemberAdmission Nothing + newtype ImageData = ImageData Text deriving (Eq, Show) @@ -1031,10 +1045,10 @@ instance ToJSON GroupMemberStatus where toJSON = J.String . textEncode toEncoding = JE.text . textEncode -acceptanceToStatus :: FullGroupPreferences -> GroupAcceptance -> GroupMemberStatus -acceptanceToStatus prefs groupAcceptance +acceptanceToStatus :: Maybe GroupMemberAdmission -> GroupAcceptance -> GroupMemberStatus +acceptanceToStatus memberAdmission groupAcceptance | groupAcceptance == GAPending = GSMemPendingApproval - | groupFeatureAllowed' SGFNewMemberReview prefs = GSMemPendingReview + | (memberAdmission >>= review) == Just MCAll = GSMemPendingReview | otherwise = GSMemAccepted memberActive :: GroupMember -> Bool @@ -1833,6 +1847,16 @@ $(JQ.deriveJSON defaultJSON ''LocalProfile) $(JQ.deriveJSON defaultJSON ''UserContactRequest) +$(JQ.deriveJSON (enumJSON $ dropPrefix "MC") ''MemberCriteria) + +$(JQ.deriveJSON defaultJSON ''GroupMemberAdmission) + +instance ToField GroupMemberAdmission where + toField = toField . encodeJSON + +instance FromField GroupMemberAdmission where + fromField = fromTextField_ decodeJSON + $(JQ.deriveJSON defaultJSON ''GroupProfile) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "IB") ''InvitedBy) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 8098db9b50..63d80657dc 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -152,7 +152,6 @@ data GroupFeature | GFSimplexLinks | GFReports | GFHistory - | GFNewMemberReview deriving (Show) data SGroupFeature (f :: GroupFeature) where @@ -165,7 +164,6 @@ data SGroupFeature (f :: GroupFeature) where SGFSimplexLinks :: SGroupFeature 'GFSimplexLinks SGFReports :: SGroupFeature 'GFReports SGFHistory :: SGroupFeature 'GFHistory - SGFNewMemberReview :: SGroupFeature 'GFNewMemberReview deriving instance Show (SGroupFeature f) @@ -192,7 +190,6 @@ groupFeatureNameText = \case GFSimplexLinks -> "SimpleX links" GFReports -> "Member reports" GFHistory -> "Recent history" - GFNewMemberReview -> "New member review" groupFeatureNameText' :: SGroupFeature f -> Text groupFeatureNameText' = groupFeatureNameText . toGroupFeature @@ -216,12 +213,11 @@ allGroupFeatures = AGF SGFFiles, AGF SGFSimplexLinks, AGF SGFReports, - AGF SGFHistory, - AGF SGFNewMemberReview + AGF SGFHistory ] groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, newMemberReview} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -231,7 +227,6 @@ groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reac SGFSimplexLinks -> simplexLinks SGFReports -> reports SGFHistory -> history - SGFNewMemberReview -> newMemberReview toGroupFeature :: SGroupFeature f -> GroupFeature toGroupFeature = \case @@ -244,7 +239,6 @@ toGroupFeature = \case SGFSimplexLinks -> GFSimplexLinks SGFReports -> GFReports SGFHistory -> GFHistory - SGFNewMemberReview -> GFNewMemberReview class GroupPreferenceI p where getGroupPreference :: SGroupFeature f -> p -> GroupFeaturePreference f @@ -256,7 +250,7 @@ instance GroupPreferenceI (Maybe GroupPreferences) where getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) instance GroupPreferenceI FullGroupPreferences where - getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history, newMemberReview} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, reports, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete @@ -266,7 +260,6 @@ instance GroupPreferenceI FullGroupPreferences where SGFSimplexLinks -> simplexLinks SGFReports -> reports SGFHistory -> history - SGFNewMemberReview -> newMemberReview {-# INLINE getGroupPreference #-} -- collection of optional group preferences @@ -279,8 +272,7 @@ data GroupPreferences = GroupPreferences files :: Maybe FilesGroupPreference, simplexLinks :: Maybe SimplexLinksGroupPreference, reports :: Maybe ReportsGroupPreference, - history :: Maybe HistoryGroupPreference, - newMemberReview :: Maybe NewMemberReviewGroupPreference + history :: Maybe HistoryGroupPreference } deriving (Eq, Show) @@ -315,7 +307,6 @@ setGroupPreference_ f pref prefs = SGFSimplexLinks -> prefs {simplexLinks = pref} SGFReports -> prefs {reports = pref} SGFHistory -> prefs {history = pref} - SGFNewMemberReview -> prefs {newMemberReview = pref} setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences setGroupTimedMessagesPreference pref prefs_ = @@ -345,8 +336,7 @@ data FullGroupPreferences = FullGroupPreferences files :: FilesGroupPreference, simplexLinks :: SimplexLinksGroupPreference, reports :: ReportsGroupPreference, - history :: HistoryGroupPreference, - newMemberReview :: NewMemberReviewGroupPreference + history :: HistoryGroupPreference } deriving (Eq, Show) @@ -404,12 +394,11 @@ defaultGroupPrefs = files = FilesGroupPreference {enable = FEOn, role = Nothing}, simplexLinks = SimplexLinksGroupPreference {enable = FEOn, role = Nothing}, reports = ReportsGroupPreference {enable = FEOn}, - history = HistoryGroupPreference {enable = FEOff}, - newMemberReview = NewMemberReviewGroupPreference {enable = FEOff} + history = HistoryGroupPreference {enable = FEOff} } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice} = @@ -436,8 +425,7 @@ defaultBusinessGroupPrefs = files = Just $ FilesGroupPreference FEOn Nothing, simplexLinks = Just $ SimplexLinksGroupPreference FEOn Nothing, reports = Just $ ReportsGroupPreference FEOff, - history = Just $ HistoryGroupPreference FEOn, - newMemberReview = Just $ NewMemberReviewGroupPreference FEOff + history = Just $ HistoryGroupPreference FEOn } data TimedMessagesPreference = TimedMessagesPreference @@ -545,10 +533,6 @@ data HistoryGroupPreference = HistoryGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show) -data NewMemberReviewGroupPreference = NewMemberReviewGroupPreference - {enable :: GroupFeatureEnabled} - deriving (Eq, Show) - class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where type GroupFeaturePreference (f :: GroupFeature) = p | p -> f sGroupFeature :: SGroupFeature f @@ -589,9 +573,6 @@ instance HasField "enable" ReportsGroupPreference GroupFeatureEnabled where instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where hasField p@HistoryGroupPreference {enable} = (\e -> p {enable = e}, enable) -instance HasField "enable" NewMemberReviewGroupPreference GroupFeatureEnabled where - hasField p@NewMemberReviewGroupPreference {enable} = (\e -> p {enable = e}, enable) - instance GroupFeatureI 'GFTimedMessages where type GroupFeaturePreference 'GFTimedMessages = TimedMessagesGroupPreference sGroupFeature = SGFTimedMessages @@ -646,12 +627,6 @@ instance GroupFeatureI 'GFHistory where groupPrefParam _ = Nothing groupPrefRole _ = Nothing -instance GroupFeatureI 'GFNewMemberReview where - type GroupFeaturePreference 'GFNewMemberReview = NewMemberReviewGroupPreference - sGroupFeature = SGFNewMemberReview - groupPrefParam _ = Nothing - groupPrefRole _ = Nothing - instance GroupFeatureNoRoleI 'GFTimedMessages instance GroupFeatureNoRoleI 'GFFullDelete @@ -662,8 +637,6 @@ instance GroupFeatureNoRoleI 'GFReports instance GroupFeatureNoRoleI 'GFHistory -instance GroupFeatureNoRoleI 'GFNewMemberReview - instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role) @@ -817,8 +790,7 @@ mergeGroupPreferences groupPreferences = files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, reports = pref SGFReports, - history = pref SGFHistory, - newMemberReview = pref SGFNewMemberReview + history = pref SGFHistory } where pref :: SGroupFeature f -> GroupFeaturePreference f @@ -835,8 +807,7 @@ toGroupPreferences groupPreferences = files = pref SGFFiles, simplexLinks = pref SGFSimplexLinks, reports = pref SGFReports, - history = pref SGFHistory, - newMemberReview = pref SGFNewMemberReview + history = pref SGFHistory } where pref :: SGroupFeature f -> Maybe (GroupFeaturePreference f) @@ -948,8 +919,6 @@ $(J.deriveJSON defaultJSON ''ReportsGroupPreference) $(J.deriveJSON defaultJSON ''HistoryGroupPreference) -$(J.deriveJSON defaultJSON ''NewMemberReviewGroupPreference) - $(J.deriveJSON defaultJSON ''GroupPreferences) instance ToField GroupPreferences where diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index db5be7b734..8f875fa008 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1637,10 +1637,10 @@ countactUserPrefText cup = case cup of viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> [StyledString] viewGroupUpdated - GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, description, image, groupPreferences = gps}} - g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', description = description', image = image', groupPreferences = gps'}} + GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, description, image, groupPreferences = gps, memberAdmission = ma}} + g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', description = description', image = image', groupPreferences = gps', memberAdmission = ma'}} m = do - let update = groupProfileUpdated <> groupPrefsUpdated + let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated if null update then [] else memberUpdated <> update @@ -1661,6 +1661,9 @@ viewGroupUpdated | otherwise = Just . plain $ groupPreferenceText (pref gps') where pref = getGroupPreference f . mergeGroupPreferences + memberAdmissionUpdated + | ma == ma' = [] + | otherwise = ["changed member admission rules"] viewGroupProfile :: GroupInfo -> [StyledString] viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {description, image, groupPreferences = gps}} = diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 6601032a79..9675959bb1 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -981,10 +981,11 @@ testCapthaScreening ps = bob <## "Use /filter 1 [name] [captcha] to enable and /filter 1 off to disable filter." -- connect with captcha screen _ <- join cath groupLink - cath #> "#privacy 123" -- sending incorrect captcha - cath <# "#privacy SimpleX-Directory!> > cath 123" + cath ##> "/_send #1(_support) text 123" -- sending incorrect captcha + cath <# "#privacy (support) 123" + cath <# "#privacy (support) SimpleX-Directory!> > cath 123" cath <## " Incorrect text, please try again." - captcha <- dropStrPrefix "#privacy SimpleX-Directory> " . dropTime <$> getTermLine cath + captcha <- dropStrPrefix "#privacy (support) SimpleX-Directory> " . dropTime <$> getTermLine cath sendCaptcha cath captcha cath <#. "#privacy SimpleX-Directory> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" @@ -1028,13 +1029,14 @@ testCapthaScreening ps = cath <## "connection request sent!" cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group, pending approval" - cath <# "#privacy SimpleX-Directory> Captcha is generated by SimpleX Directory service." + cath <# "#privacy (support) SimpleX-Directory> Captcha is generated by SimpleX Directory service." cath <## "" cath <## "Send captcha text to join the group privacy." - dropStrPrefix "#privacy SimpleX-Directory> " . dropTime <$> getTermLine cath + dropStrPrefix "#privacy (support) SimpleX-Directory> " . dropTime <$> getTermLine cath sendCaptcha cath captcha = do - cath #> ("#privacy " <> captcha) - cath <# ("#privacy SimpleX-Directory!> > cath " <> captcha) + cath ##> ("/_send #1(_support) text " <> captcha) + cath <# ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) SimpleX-Directory!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index 0c0f0b0ddc..dcd18a9818 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -200,14 +200,14 @@ testPaginationAllChatTypes = ts7 <- iso8601Show <$> getCurrentTime - getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off")] + getChats_ alice "count=10" [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice "count=3" [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on")] getChats_ alice ("after=" <> ts2 <> " count=2") [(":3", ""), ("<@cath", "")] - getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", "New member review: off"), (":3", "")] - getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", "")] + getChats_ alice ("before=" <> ts5 <> " count=2") [("#team", "Recent history: on"), (":3", "")] + getChats_ alice ("after=" <> ts3 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", "")] getChats_ alice ("before=" <> ts4 <> " count=10") [(":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] - getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "New member review: off"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("after=" <> ts1 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] + getChats_ alice ("before=" <> ts7 <> " count=10") [("*", "psst"), ("@dan", "hey"), ("#team", "Recent history: on"), (":3", ""), ("<@cath", ""), ("@bob", "hey")] getChats_ alice ("after=" <> ts7 <> " count=10") [] getChats_ alice ("before=" <> ts1 <> " count=10") [] @@ -219,11 +219,11 @@ testPaginationAllChatTypes = alice ##> "/_settings #1 {\"enableNtfs\":\"all\",\"favorite\":true}" alice <## "ok" - getChats_ alice queryFavorite [("#team", "New member review: off"), ("@bob", "hey")] + getChats_ alice queryFavorite [("#team", "Recent history: on"), ("@bob", "hey")] getChats_ alice ("before=" <> ts4 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "New member review: off")] + getChats_ alice ("before=" <> ts5 <> " count=1 " <> queryFavorite) [("#team", "Recent history: on")] getChats_ alice ("after=" <> ts1 <> " count=1 " <> queryFavorite) [("@bob", "hey")] - getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "New member review: off")] + getChats_ alice ("after=" <> ts4 <> " count=1 " <> queryFavorite) [("#team", "Recent history: on")] let queryUnread = "{\"type\": \"filters\", \"favorite\": false, \"unread\": true}" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e238b4b1bb..5969a49b7b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -387,7 +387,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice forM_ ([1 .. 10] :: [Int]) $ \n -> bob <# ("#team alice> " <> show n) -- All messages are unread for bob, should return area around unread - bob #$> ("/_get chat #1 initial=2", chat, [(0, "New member review: off"), (0, "connected"), (0, "1"), (0, "2"), (0, "3")]) + bob #$> ("/_get chat #1 initial=2", chat, [(0, "Recent history: on"), (0, "connected"), (0, "1"), (0, "2"), (0, "3")]) -- Read next 2 items let itemIds = intercalate "," $ map groupItemId [1 .. 2] @@ -1571,7 +1571,6 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "SimpleX links: on" alice <## "Member reports: on" alice <## "Recent history: on" - alice <## "New member review: off" bobAddedDan :: HasCallStack => TestCC -> IO () bobAddedDan cc = do cc <## "#team: bob added dan (Daniel) to the group (connecting...)" @@ -3040,22 +3039,18 @@ testGLinkReviewMember = \alice bob cath dan eve -> do createGroup4 "team" alice (bob, GRMember) (cath, GRModerator) (dan, GRModerator) - alice ##> "/set new member review #team on" - alice <## "updated group preferences:" - alice <## "New member review: on" + alice ##> "/set admission review #team all" + alice <## "changed member admission rules" concurrentlyN_ [ do bob <## "alice updated group #team:" - bob <## "updated group preferences:" - bob <## "New member review: on", + bob <## "changed member admission rules", do cath <## "alice updated group #team:" - cath <## "updated group preferences:" - cath <## "New member review: on", + cath <## "changed member admission rules", do dan <## "alice updated group #team:" - dan <## "updated group preferences:" - dan <## "New member review: on" + dan <## "changed member admission rules" ] alice ##> "/create link #team" @@ -3164,22 +3159,18 @@ testGLinkApproveThenReviewMember = \alice bob cath dan eve -> do createGroup4 "team" alice (bob, GRMember) (cath, GRModerator) (dan, GRModerator) - alice ##> "/set new member review #team on" - alice <## "updated group preferences:" - alice <## "New member review: on" + alice ##> "/set admission review #team all" + alice <## "changed member admission rules" concurrentlyN_ [ do bob <## "alice updated group #team:" - bob <## "updated group preferences:" - bob <## "New member review: on", + bob <## "changed member admission rules", do cath <## "alice updated group #team:" - cath <## "updated group preferences:" - cath <## "New member review: on", + cath <## "changed member admission rules", do dan <## "alice updated group #team:" - dan <## "updated group preferences:" - dan <## "New member review: on" + dan <## "changed member admission rules" ] alice ##> "/create link #team" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 2ca5a0f5b0..b9068a2e21 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -310,8 +310,7 @@ groupFeatures'' dir = ((dir, "Files and media: on"), Nothing, Nothing), ((dir, "SimpleX links: on"), Nothing, Nothing), ((dir, "Member reports: on"), Nothing, Nothing), - ((dir, "Recent history: on"), Nothing, Nothing), - ((dir, "New member review: off"), Nothing, Nothing) + ((dir, "Recent history: on"), Nothing, Nothing) ] itemId :: Int -> String diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index ea8d54a0bd..ab2b4b0af2 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -101,13 +101,13 @@ testChatPreferences :: Maybe Preferences testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}} 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, newMemberReview = Nothing} +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} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), contactLink = Nothing, preferences = testChatPreferences} testGroupProfile :: GroupProfile -testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, image = Nothing, groupPreferences = testGroupPreferences} +testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, image = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do From c12817ac81467f62673d9d6c03a26ce4b1aaf394 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 3 Apr 2025 09:36:28 +0000 Subject: [PATCH 03/72] core: rework knocking protocol (#5802) --- src/Simplex/Chat/Library/Commands.hs | 8 ++-- src/Simplex/Chat/Library/Subscriber.hs | 53 ++++++++++---------------- src/Simplex/Chat/Protocol.hs | 6 +-- tests/ChatTests/Groups.hs | 8 ---- 4 files changed, 27 insertions(+), 48 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4bcfcdd1b0..1895542bb0 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2054,19 +2054,19 @@ processChatCommand' vr = \case let m' = m {memberStatus = GSMemPendingReview} pure $ CRMemberAccepted user gInfo m' Nothing -> do - let msg = XGrpLinkAcpt role (Just $ memberId' m) + let msg = XGrpLinkAcpt role (memberId' m) void $ sendDirectMemberMessage mConn msg groupId introduceToRemaining vr user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m GSMemConnected role pure $ CRMemberAccepted user gInfo m' Nothing -> throwChatError CEGroupMemberNotActive - GSMemPendingReview | memberCategory m /= GCInviteeMember -> do -- only other admins can review + GSMemPendingReview -> do let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo let rcpModMs' = filter memberCurrent modMs - msg = XGrpLinkAcpt role (Just $ memberId' m) - void $ sendGroupMessage user gInfo scope rcpModMs' msg + msg = XGrpLinkAcpt role (memberId' m) + void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m newMemberStatus role pure $ CRMemberAccepted user gInfo m' where diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2d747298ee..9a7d6144c4 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -815,7 +815,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memCategory == GCPreMember && not (memberPending membership)) $ + when (memCategory == GCPreMember) $ probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn} -> forM_ activeConn $ \Connection {connStatus} -> @@ -875,7 +875,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName XInfo p -> xInfoMember gInfo' m'' p brokerTs XGrpLinkMem p -> xGrpLinkMem gInfo' m'' conn' p - XGrpLinkAcpt role memberId_ -> xGrpLinkAcpt gInfo' m'' role memberId_ + XGrpLinkAcpt role memberId -> xGrpLinkAcpt gInfo' m'' role memberId XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> xGrpMemInv gInfo' m'' memId introInv @@ -2082,27 +2082,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do m' <- processMemberProfileUpdate gInfo m p' False Nothing withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True - unless (memberStatus == GSMemPendingApproval) $ do - let connectedIncognito = memberIncognito membership - probeMatchingMemberContact m' connectedIncognito + let connectedIncognito = memberIncognito membership + probeMatchingMemberContact m' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" - xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> Maybe MemberId -> CM () - xGrpLinkAcpt gInfo@GroupInfo {groupId, membership} m role memberId_ = case memberId_ of - Nothing -> processUserAccepted -- backwards compatibility - Just memberId - | sameMemberId memberId membership -> processUserAccepted - | otherwise -> - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case - Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" - Right referencedMember -> do - referencedMember' <- withFastStore' $ \db -> updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role - when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' - toView $ CRMemberAcceptedByOther user gInfo m referencedMember' - where - newMemberStatus refMem = case memberConn refMem of - Just c | connReady c -> GSMemConnected - _ -> GSMemAnnounced + xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> MemberId -> CM () + xGrpLinkAcpt gInfo@GroupInfo {groupId, membership} m role memberId + | sameMemberId memberId membership = processUserAccepted + | otherwise = + withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case + Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" + Right referencedMember -> do + referencedMember' <- withFastStore' $ \db -> updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role + when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' + toView $ CRMemberAcceptedByOther user gInfo m referencedMember' + where + newMemberStatus refMem = case memberConn refMem of + Just c | connReady c -> GSMemConnected + _ -> GSMemAnnounced where processUserAccepted = do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role @@ -2111,19 +2108,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CIRcvGroupFeature gInfo maybeCreateGroupDescrLocal gInfo m - -- Here we can safely probe all known members, because host sends XGrpLinkAcpt before remaining introductions - let connectedIncognito = memberIncognito membership - members <- withStore' $ \db -> getGroupMembers db vr user gInfo - forM_ (filter memberActive members) $ \mem -> - probeMatchingMemberContact mem connectedIncognito introduceToRemainingMembers acceptedMember = do - case memberConn acceptedMember of - Just mConn -> do - let msg = XGrpLinkAcpt role (Just $ memberId' acceptedMember) - void $ sendDirectMemberMessage mConn msg groupId - introduceToRemaining vr user gInfo acceptedMember - when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember - Nothing -> messageError "x.grp.link.acpt error: no active accepted member connection" + introduceToRemaining vr user gInfo acceptedMember + when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember maybeCreateGroupDescrLocal :: GroupInfo -> GroupMember -> CM () maybeCreateGroupDescrLocal gInfo@GroupInfo {groupProfile = GroupProfile {description}} m = diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 5a60bba255..e02a43f1cd 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -346,7 +346,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json - XGrpLinkAcpt :: GroupMemberRole -> Maybe MemberId -> ChatMsgEvent 'Json -- Maybe MemberId for compatibility + XGrpLinkAcpt :: GroupMemberRole -> MemberId -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -1099,7 +1099,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" - XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "role" <*> opt "memberId" + XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "role" <*> p "memberId" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1163,7 +1163,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] - XGrpLinkAcpt role memberId -> o $ ("memberId" .=? memberId) ["role" .= role] + XGrpLinkAcpt role memberId -> o ["role" .= role, "memberId" .= memberId] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 5969a49b7b..02d39b0952 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3115,10 +3115,6 @@ testGLinkReviewMember = (bob "/_accept member #1 5 member" - alice <## "bad chat command: member should be pending approval and invitee, or pending review and not invitee" - -- accept member dan ##> "/_accept member #1 5 member" concurrentlyN_ @@ -3274,10 +3270,6 @@ testGLinkApproveThenReviewMember = (bob "/_accept member #1 5 member" - alice <## "bad chat command: member should be pending approval and invitee, or pending review and not invitee" - -- accept member dan ##> "/_accept member #1 5 member" concurrentlyN_ From d85ac4af04bb30cbdff8e450d5a37cd8755a4b6d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:25:45 +0000 Subject: [PATCH 04/72] core: member support chat stats (#5803) * core: member support chat stats * schema * update counts * mark read wip * dec counts on read * rename * plans * test, fixes * plans * refactor * rename --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 28 +-- src/Simplex/Chat/Library/Internal.hs | 39 +++- src/Simplex/Chat/Library/Subscriber.hs | 4 +- src/Simplex/Chat/Store/Connections.hs | 7 +- src/Simplex/Chat/Store/Groups.hs | 42 ++--- src/Simplex/Chat/Store/Messages.hs | 170 +++++++++++++----- .../Migrations/M20250310_group_scope.hs | 23 +-- .../SQLite/Migrations/chat_query_plans.txt | 149 ++++++++------- .../Store/SQLite/Migrations/chat_schema.sql | 8 +- src/Simplex/Chat/Store/Shared.hs | 27 +-- src/Simplex/Chat/Types.hs | 17 +- src/Simplex/Chat/View.hs | 15 +- tests/ChatClient.hs | 10 +- tests/ChatTests/Groups.hs | 150 +++++++++++++++- 15 files changed, 475 insertions(+), 216 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1ea2991c24..f3325d261d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -662,7 +662,6 @@ data ChatResponse | CRWelcome {user :: User} | CRGroupCreated {user :: User, groupInfo :: GroupInfo} | CRGroupMembers {user :: User, group :: Group} - | CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} -- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]} -- | CRGroupConversationsDeleted {user :: User, groupInfo :: GroupInfo, deletedGroupConversations :: [GroupConversation]} | CRContactsList {user :: User, contacts :: [Contact]} @@ -851,6 +850,7 @@ data ChatResponse data TerminalEvent = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} + | TEMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Show) data DeletedRcvQueue = DeletedRcvQueue diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 1895542bb0..781a46270b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -989,7 +989,7 @@ processChatCommand' vr = \case pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId - APIChatRead chatRef@(ChatRef cType chatId _scope) -> withUser $ \_ -> case cType of + APIChatRead chatRef@(ChatRef cType chatId scope) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId ts <- liftIO getCurrentTime @@ -1000,11 +1000,14 @@ processChatCommand' vr = \case forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user CTGroup -> do - user <- withFastStore $ \db -> getUserByGroupId db chatId + (user, gInfo) <- withFastStore $ \db -> do + user <- getUserByGroupId db chatId + gInfo <- getGroupInfo db vr user chatId + pure (user, gInfo) ts <- liftIO getCurrentTime timedItems <- withFastStore' $ \db -> do timedItems <- getGroupUnreadTimedItems db user chatId - updateGroupChatItemsRead db user chatId + updateGroupChatItemsRead db user gInfo scope setGroupChatItemsDeleteAt db user chatId timedItems ts forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user @@ -1014,8 +1017,7 @@ processChatCommand' vr = \case ok user CTContactRequest -> pure $ chatCmdError Nothing "not supported" CTContactConnection -> pure $ chatCmdError Nothing "not supported" - -- TODO [knocking] read scope? - APIChatItemsRead chatRef@(ChatRef cType chatId _scope) itemIds -> withUser $ \_ -> case cType of + APIChatItemsRead chatRef@(ChatRef cType chatId scope) itemIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId timedItems <- withFastStore' $ \db -> do @@ -1024,9 +1026,12 @@ processChatCommand' vr = \case forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user CTGroup -> do - user <- withFastStore $ \db -> getUserByGroupId db chatId + (user, gInfo) <- withFastStore $ \db -> do + user <- getUserByGroupId db chatId + gInfo <- getGroupInfo db vr user chatId + pure (user, gInfo) timedItems <- withFastStore' $ \db -> do - timedItems <- updateGroupChatItemsReadList db user chatId itemIds + timedItems <- updateGroupChatItemsReadList db user gInfo scope itemIds setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user @@ -2317,11 +2322,10 @@ processChatCommand' vr = \case groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId ListMemberSupportChats gName -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfoByName db vr user gName - -- TODO [knocking] delete all support chats (chat items) if role is lowered? - assertUserGroupRole gInfo GRModerator - supportMems <- withFastStore' $ \db -> getSupportMembers db vr user gInfo - pure $ CRMemberSupportChats user gInfo supportMems + groupId <- withFastStore $ \db -> getGroupIdByName db user gName + (Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + let memberSupportChats = filter (isJust . supportChat) members + pure $ CRTerminalEvent $ TEMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> CRGroupsList user <$> withFastStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 5ef97442da..e0a9f7d040 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1354,16 +1354,16 @@ mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = pure (gInfo', m, Just scopeInfo) | otherwise -> do referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId - -- TODO [knocking] return patched _referredMember too? + -- TODO [knocking] return patched _referredMember' too? (_referredMember', scopeInfo) <- liftIO $ mkMemberSupportChatInfo referredMember pure (gInfo, m, Just scopeInfo) mkGroupSupportChatInfo :: GroupInfo -> IO (GroupInfo, GroupChatScopeInfo) -mkGroupSupportChatInfo gInfo@GroupInfo {modsSupportChat} = - case modsSupportChat of +mkGroupSupportChatInfo gInfo@GroupInfo {membership} = + case supportChat membership of Nothing -> do chatTs <- getCurrentTime - let gInfo' = gInfo {modsSupportChat = Just $ GroupSupportChat chatTs True} + let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 1 0 0}} scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} pure (gInfo', scopeInfo) Just _supportChat -> @@ -1375,7 +1375,7 @@ mkMemberSupportChatInfo m@GroupMember {supportChat} = case supportChat of Nothing -> do chatTs <- getCurrentTime - let m' = m {supportChat = Just $ GroupSupportChat chatTs True} + let m' = m {supportChat = Just $ GroupSupportChat chatTs 1 0 0} scopeInfo = GCSIMemberSupport {groupMember_ = Just m'} pure (m', scopeInfo) Just _supportChat -> @@ -2008,7 +2008,7 @@ saveSndChatItems :: saveSndChatItems user cd itemsData itemTimed live = do createdAt <- liftIO getCurrentTime when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - withStore' (\db -> updateChatTs db user cd createdAt) + withStore' (\db -> updateChatTsStats db 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)) @@ -2034,7 +2034,6 @@ saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime withStore' $ \db -> do - when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt (mentions' :: Map MemberName CIMention, userMention) <- case cd of CDGroupRcv g@GroupInfo {membership} _scope _m -> do mentions' <- getRcvCIMentions db user g ft_ mentions @@ -2044,12 +2043,20 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' in pure (mentions', userMention') CDDirectRcv _ -> pure (M.empty, False) + when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTsStats db user cd createdAt (chatStatsCounts userMention) (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt case cd of CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci + where + chatStatsCounts :: Bool -> Maybe (Int, MemberAttention, Int) + chatStatsCounts userMention = case cd of + CDGroupRcv _g (Just scope) m -> do + let unread = fromEnum $ ciCreateStatus content == CISRcvNew + in Just (unread, memberAttentionChange unread m scope, fromEnum userMention) + _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d @@ -2268,14 +2275,28 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do where updateChat :: DB.Connection -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () updateChat db createdAt cd contents - | any ciRequiresAttention contents || contactChatDeleted cd = updateChatTs db user cd createdAt + | any ciRequiresAttention contents || contactChatDeleted cd = updateChatTsStats db user cd createdAt chatStatsCounts | otherwise = pure () + where + chatStatsCounts :: Maybe (Int, MemberAttention, Int) + chatStatsCounts = case cd of + CDGroupRcv _g (Just scope) m -> do + let unread = length $ filter ciRequiresAttention contents + in Just (unread, memberAttentionChange unread m scope, 0) + _ -> Nothing createACIs :: DB.Connection -> UTCTime -> UTCTime -> ChatDirection c d -> [CIContent d] -> [IO AChatItem] createACIs db itemTs createdAt cd = map $ \content -> do ciId <- createNewChatItemNoMsg db user cd content itemTs createdAt let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci +memberAttentionChange :: Int -> GroupMember -> GroupChatScopeInfo -> MemberAttention +memberAttentionChange unread m = \case + GCSIMemberSupport (Just m') + | groupMemberId' m' == groupMemberId' m -> MAInc unread + | otherwise -> MAReset + GCSIMemberSupport Nothing -> MAInc 0 + createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> @@ -2283,7 +2304,7 @@ createLocalChatItems :: UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do - withStore' $ \db -> updateChatTs db user cd createdAt + withStore' $ \db -> updateChatTsStats db user cd createdAt Nothing (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) unless (null errs) $ toView $ CRChatErrors (Just user) errs pure items diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9a7d6144c4..e02b046a33 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2076,7 +2076,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoMember gInfo m p' brokerTs = void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs) xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM () - xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory, memberStatus} Connection {viaGroupLink} p' = do + xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do @@ -2087,7 +2087,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else messageError "x.grp.link.mem error: invalid group link host profile update" xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> MemberId -> CM () - xGrpLinkAcpt gInfo@GroupInfo {groupId, membership} m role memberId + xGrpLinkAcpt gInfo@GroupInfo {membership} m role memberId | sameMemberId memberId membership = processUserAccepted | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index b63ea91126..617fef1759 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -136,17 +136,18 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + 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, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + 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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 054fc24519..e2045165d7 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -54,7 +54,6 @@ module Simplex.Chat.Store.Groups getGroupMemberByMemberId, getGroupMembers, getGroupModerators, - getSupportMembers, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupChatItems, @@ -177,11 +176,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe BoolInt) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, 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, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, supportChatUnanswered)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnanswered)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, 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, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -280,17 +279,18 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + 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, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -361,8 +361,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing, - modsSupportChat = Nothing + customData = Nothing } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -432,8 +431,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing, - modsSupportChat = Nothing + customData = Nothing }, groupMemberId ) @@ -770,10 +768,10 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + 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 FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -837,7 +835,8 @@ groupMemberQuery = SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -924,14 +923,6 @@ getGroupModerators db vr 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, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) -getSupportMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getSupportMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr 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.support_chat_ts IS NOT NULL") - (userId, userId, groupId, userContactId) - getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) @@ -1570,17 +1561,18 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + 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, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 230028e1ce..2ef501e931 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -10,6 +11,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} @@ -33,7 +35,8 @@ module Simplex.Chat.Store.Messages getPendingGroupMessages, deletePendingGroupMessage, deleteOldMessages, - updateChatTs, + MemberAttention (..), + updateChatTsStats, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -141,7 +144,7 @@ import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import Data.Either (fromRight, rights) import Data.Int (Int64) -import Data.List (sortBy) +import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -362,8 +365,11 @@ deleteOldMessages db createdAtCutoff = do type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId) -updateChatTs :: DB.Connection -> User -> ChatDirection c d -> UTCTime -> IO () -updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirection of +data MemberAttention = MAInc Int | MAReset + deriving (Show) + +updateChatTsStats :: DB.Connection -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO () +updateChatTsStats db User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of DirectChat Contact {contactId} -> DB.execute db @@ -374,16 +380,38 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti db "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" (chatTs, userId, groupId) - GroupChat GroupInfo {groupId} (Just (GCSIMemberSupport Nothing)) -> do - DB.execute - db - "UPDATE groups SET mods_support_chat_ts = ? WHERE user_id = ? AND group_id = ?" - (chatTs, userId, groupId) - GroupChat _gInfo (Just (GCSIMemberSupport (Just GroupMember {groupMemberId}))) -> do - DB.execute - db - "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" - (chatTs, groupMemberId) + GroupChat GroupInfo {membership} (Just GCSIMemberSupport {groupMember_}) -> do + let gmId = groupMemberId' $ fromMaybe membership groupMember_ + case chatStats_ of + Nothing -> + DB.execute + db + "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" + (chatTs, gmId) + Just (unread, MAInc unanswered, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, unanswered, mentions, gmId) + Just (unread, MAReset, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, mentions, gmId) LocalChat NoteFolder {noteFolderId} -> DB.execute db @@ -544,7 +572,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + 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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -1260,10 +1289,10 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do getGroupChatInitial_ db user g scopeInfo contentFilter count getGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo -getGroupChatScopeInfo db vr user GroupInfo {modsSupportChat} = \case - GCSMemberSupport Nothing -> case modsSupportChat of +getGroupChatScopeInfo db vr user GroupInfo {membership} = \case + GCSMemberSupport Nothing -> case supportChat membership of Nothing -> throwError $ SEInternalError "no moderators support chat" - Just _modsSupportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} + Just _supportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} GCSMemberSupport (Just gmId) -> do m <- getGroupMemberById db vr user gmId case supportChat m of @@ -1857,8 +1886,8 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i (deleteAt, userId, contactId, chatItemId) pure (chatItemId, deleteAt) -updateGroupChatItemsRead :: DB.Connection -> User -> GroupId -> IO () -updateGroupChatItemsRead db User {userId} groupId = do +updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> IO () +updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope = do currentTs <- getCurrentTime DB.execute db @@ -1867,6 +1896,20 @@ updateGroupChatItemsRead db User {userId} groupId = do WHERE user_id = ? AND group_id = ? AND item_status = ? |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) + case scope of + Nothing -> pure () + Just GCSMemberSupport {groupMemberId_} -> do + let gmId = fromMaybe (groupMemberId' membership) groupMemberId_ + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0 + WHERE group_member_id = ? + |] + (Only gmId) getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> IO [(ChatItemId, Int)] getGroupUnreadTimedItems db User {userId} groupId = @@ -1879,33 +1922,63 @@ getGroupUnreadTimedItems db User {userId} groupId = |] (userId, groupId, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> User -> GroupId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] -updateGroupChatItemsReadList db User {userId} groupId itemIds = do +updateGroupChatItemsReadList :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] +updateGroupChatItemsReadList db User {userId} GroupInfo {groupId, membership} scope itemIds = do currentTs <- getCurrentTime - catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds + -- Possible improvement is to differentiate retrieval queries for each scope, + -- but we rely on UI to not pass item IDs from incorrect scope. + readItemsData <- catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds + updateChatStats readItemsData + pure $ timedItems readItemsData where - getUpdateGroupItem currentTs itemId = do - ttl_ <- maybeFirstRow fromOnly getUnreadTimedItem - setItemRead - pure $ (itemId,) <$> ttl_ + getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) + getUpdateGroupItem currentTs itemId = + maybeFirstRow id $ + DB.query + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? + RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention + |] + (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) + updateChatStats :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> IO () + updateChatStats readItemsData = case scope of + Nothing -> pure () + Just GCSMemberSupport {groupMemberId_} -> do + let unread = length readItemsData + (unanswered, mentions) = decStats + gmId = fromMaybe (groupMemberId' membership) groupMemberId_ + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ? + WHERE group_member_id = ? + |] + (unread, unanswered, mentions, gmId) + where + decStats :: (Int, Int) + decStats = foldl' countItem (0, 0) readItemsData + where + countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) + countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = + let unanswered' = case (groupMemberId_, itemGMId_) of + (Just scopeGMId, Just itemGMId) | itemGMId == scopeGMId -> unanswered + 1 + _ -> unanswered + mentions' = case userMention_ of + Just (BI True) -> mentions + 1 + _ -> mentions + in (unanswered', mentions') + timedItems :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> [(ChatItemId, Int)] + timedItems = foldl' addTimedItem [] where - getUnreadTimedItem = - DB.query - db - [sql| - SELECT timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - |] - (userId, groupId, CISRcvNew, itemId) - setItemRead = - DB.execute - db - [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? - |] - (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) + addTimedItem acc (itemId, Just ttl, Nothing, _, _) = (itemId, ttl) : acc + addTimedItem acc _ = acc + +deriving instance Show BoolInt setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do @@ -2698,19 +2771,22 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, - rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_unanswered, + 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, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, - dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_unanswered + 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 FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs index fa48bb8660..51aef80563 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs @@ -5,24 +5,16 @@ module Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- - group_scope_group_member_id points either to member (for chat with member as admin), --- or to membership (for chat with admins as member); it is used to find chat items for scope, --- when we know from context what group member id we are looking for; --- - to learn scope of chat item when context is not known, group member is joined and scope --- is decided based on whether member is of user member category (membership -> chat with admins), or not. --- TODO [knocking] TBC schema --- TODO - group_members.support_chat_unanswered - don't persist, calculate on the fly? --- TODO - review indexes (drop idx_chat_items_groups_item_ts?) +-- TODO [knocking] review indexes (drop idx_chat_items_groups_item_ts?) m20250310_group_scope :: Query m20250310_group_scope = [sql| ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; -ALTER TABLE groups ADD COLUMN mods_support_chat_ts TEXT; -ALTER TABLE groups ADD COLUMN mods_support_chat_unanswered INTEGER; - ALTER TABLE group_members ADD COLUMN support_chat_ts TEXT; -ALTER TABLE group_members ADD COLUMN support_chat_unanswered INTEGER; +ALTER TABLE group_members ADD COLUMN support_chat_items_unread INTEGER NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_mentions INTEGER NOT NULL DEFAULT 0; ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; @@ -49,10 +41,9 @@ ALTER TABLE chat_items DROP COLUMN group_scope_tag; ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; ALTER TABLE group_members DROP COLUMN support_chat_ts; -ALTER TABLE group_members DROP COLUMN support_chat_unanswered; - -ALTER TABLE groups DROP COLUMN mods_support_chat_ts; -ALTER TABLE groups DROP COLUMN mods_support_chat_unanswered; +ALTER TABLE group_members DROP COLUMN support_chat_items_unread; +ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; +ALTER TABLE group_members DROP COLUMN support_chat_items_mentions; ALTER TABLE group_profiles DROP COLUMN member_admission; |] 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 3f8f114a4a..ebbf578434 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -37,17 +37,18 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + 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, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + 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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -210,21 +211,6 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - -Plan: -SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? - -Plan: -SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contact_profiles SET display_name = ?, @@ -559,7 +545,8 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + 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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -610,6 +597,46 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? + RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention + +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? @@ -708,19 +735,22 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, - rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_unanswered, + 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, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, - dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_unanswered + 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 FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id @@ -795,17 +825,18 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + 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, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -841,10 +872,10 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + 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 FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -1223,6 +1254,16 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0 + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_profiles SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? @@ -4474,12 +4515,12 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + 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 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 @@ -4497,12 +4538,12 @@ Query: g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + 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 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 @@ -4518,7 +4559,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4550,7 +4592,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4574,7 +4617,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4598,7 +4642,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4622,7 +4667,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4646,7 +4692,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + 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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4666,30 +4713,6 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) -Query: - SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.support_chat_ts IS NOT NULL -Plan: -SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i @@ -5915,10 +5938,6 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE groups SET mods_support_chat_ts = ? WHERE user_id = ? AND group_id = ? -Plan: -SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE groups SET send_rcpts = NULL Plan: SCAN groups diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 3fde7ce08e..b87729b6e8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -134,9 +134,7 @@ CREATE TABLE groups( business_xcontact_id BLOB NULL, customer_member_id BLOB NULL, chat_item_ttl INTEGER, - local_alias TEXT DEFAULT '', - mods_support_chat_ts TEXT, - mods_support_chat_unanswered INTEGER, -- received + local_alias TEXT DEFAULT '', -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -170,7 +168,9 @@ CREATE TABLE group_members( peer_chat_max_version INTEGER NOT NULL DEFAULT 1, member_restriction TEXT, support_chat_ts TEXT, - support_chat_unanswered INTEGER, + support_chat_items_unread INTEGER NOT NULL DEFAULT 0, + support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0, + support_chat_items_mentions INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 093382f3ee..847c74715b 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -577,32 +577,35 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. (Maybe UTCTime, Maybe BoolInt) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow -type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Maybe BoolInt) +type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. (modsSupportChatTs_, modsSupportChatUnanswered_) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow - modsSupportChat = case (modsSupportChatTs_, modsSupportChatUnanswered_) of - (Just modsChatTs, unanswered_) -> Just GroupSupportChat {chatTs = modsChatTs, unanswered = maybe False unBI unanswered_} - _ -> Nothing - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData, modsSupportChat} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnanswered_)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - supportChat = case (supportChatTs_, supportChatUnanswered_) of - (Just chatTs, unanswered_) -> Just GroupSupportChat {chatTs, unanswered = maybe False unBI unanswered_} + supportChat = case supportChatTs_ of + Just chatTs -> + Just GroupSupportChat { + chatTs, + unread = supportChatUnread, + memberAttention = supportChatMemberAttention, + mentions = supportChatMentions + } _ -> Nothing in GroupMember {..} @@ -618,12 +621,12 @@ groupInfoQuery = g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, - g.mods_support_chat_ts, g.mods_support_chat_unanswered, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + 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 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 diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 6ce902a48d..7b21187559 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -419,14 +419,7 @@ data GroupInfo = GroupInfo chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, - customData :: Maybe CustomData, - modsSupportChat :: Maybe GroupSupportChat - } - deriving (Eq, Show) - -data GroupSupportChat = GroupSupportChat - { chatTs :: UTCTime, - unanswered :: Bool + customData :: Maybe CustomData } deriving (Eq, Show) @@ -851,6 +844,14 @@ data GroupMember = GroupMember } deriving (Eq, Show) +data GroupSupportChat = GroupSupportChat + { chatTs :: UTCTime, + unread :: Int64, + memberAttention :: Int64, + mentions :: Int64 + } + deriving (Eq, Show) + data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8f875fa008..32dd81ee8b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -186,7 +186,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g - CRMemberSupportChats u _g ms -> ttyUser u $ viewSupportMembers ms -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] -- CRGroupConversationsDeleted u _g _conversations -> ttyUser u [] CRGroupsList u gs -> ttyUser u $ viewGroupsList gs @@ -454,6 +453,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRTerminalEvent te -> case te of TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason] + TEMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss @@ -1197,10 +1197,17 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt | not (showMessages $ memberSettings m) = ["blocked"] | otherwise = [] -viewSupportMembers :: [GroupMember] -> [StyledString] -viewSupportMembers = map groupMember +viewMemberSupportChats :: GroupInfo -> [GroupMember] -> [StyledString] +viewMemberSupportChats GroupInfo {membership} ms = support <> map groupMember ms where - groupMember m = memIncognito m <> ttyFullMember m <> ", id: " <> sShow (groupMemberId' m) + support = case supportChat membership of + Just sc -> ["support: " <> chatStats sc] + Nothing -> [] + groupMember m@GroupMember {supportChat} = case supportChat of + Just sc -> memIncognito m <> ttyFullMember m <> (" (id " <> sShow (groupMemberId' m) <> "): ") <> chatStats sc + Nothing -> "" + chatStats GroupSupportChat {unread, memberAttention, mentions} = + "unread: " <> sShow unread <> ", require attention: " <> sShow memberAttention <> ", mentions: " <> sShow mentions viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected ct userIncognitoProfile testView = diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 33b6978ca3..b360061ef0 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -459,10 +459,16 @@ testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ test_ _ = error "expected 3 chat clients" testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () -testChat4 = testChatCfg4 testCfg +testChat4 = testChatCfgOpts4 testCfg testOpts testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () -testChatCfg4 cfg p1 p2 p3 p4 test = testChatN cfg testOpts [p1, p2, p3, p4] test_ +testChatCfg4 cfg = testChatCfgOpts4 cfg testOpts + +testChatOpts4 :: HasCallStack => ChatOpts -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatOpts4 = testChatCfgOpts4 testCfg + +testChatCfgOpts4 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatCfgOpts4 cfg opts p1 p2 p3 p4 test = testChatN cfg opts [p1, p2, p3, p4] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3, tc4] = test tc1 tc2 tc3 tc4 diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 02d39b0952..00d25b533f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -195,6 +195,7 @@ chatGroupTests = do describe "group scoped messages" $ do it "should send scoped messages to support (single moderator)" testScopedSupportSingleModerator it "should send scoped messages to support (many moderators)" testScopedSupportManyModerators + it "should correctly maintain unread stats for support chats" testScopedSupportUnreadStats testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -6889,8 +6890,8 @@ testUpdatedMentionNames = do test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" `shouldBe` "hello @alice @alice_1" where - test mentions t = - let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentions + test mentionsMap t = + let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentionsMap in msgContentText mc' mm = M.fromList . map (second mentionedMember) mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = ciMentionMember <$> name_} @@ -6965,10 +6966,147 @@ testScopedSupportManyModerators = cath <## "chat db error: SEInternalError {message = \"no support chat\"}" alice ##> "/member support chats #team" - alice <## "bob (Bob), id: 2" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" - dan <## "bob (Bob), id: 3" + dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" bob ##> "/member support chats #team" - bob <## "#team: you have insufficient permissions for this action, the required role is moderator" + bob <## "support: unread: 0, require attention: 0, mentions: 0" cath ##> "/member support chats #team" - cath <## "#team: you have insufficient permissions for this action, the required role is moderator" + cath TestParams -> IO () +testScopedSupportUnreadStats = + testChatOpts4 opts aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) + + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + alice ##> "/_send #1(_support:2) text 3" + alice <# "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + dan <# "#team (support: bob) alice> 3" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 1, require attention: 0, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 1, require attention: 0, mentions: 0" + + bob ##> "/_send #1(_support) text 4" + bob <# "#team (support) 4" + [alice, dan] *<# "#team (support: bob) bob> 4" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 1, require attention: 0, mentions: 0" + + dan ##> "/_send #1(_support:3) text 5" + dan <# "#team (support: bob) 5" + alice <# "#team (support: bob) dan> 5" + bob <# "#team (support) dan> 5" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 0, mentions: 0" + -- In test "answering" doesn't reset unanswered, but in UI items would be marked read on opening chat + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 2, require attention: 0, mentions: 0" + + threadDelay 1000000 + + dan ##> "/_send #1(_support:3) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@alice 6\"}, \"mentions\": {\"alice\": 1}}]" + dan <# "#team (support: bob) @alice 6" + alice <# "#team (support: bob) dan!> @alice 6" + bob <# "#team (support) dan> @alice 6" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 0, mentions: 1" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 3, require attention: 0, mentions: 0" + + aliceMentionedByDanItemId <- lastItemId alice + + threadDelay 1000000 + + bob ##> "/_send #1(_support) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@alice 7\"}, \"mentions\": {\"alice\": 1}}]" + bob <# "#team (support) @alice 7" + alice <# "#team (support: bob) bob!> @alice 7" + dan <# "#team (support: bob) bob> @alice 7" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 1, mentions: 2" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 3, require attention: 2, mentions: 0" + bob ##> "/member support chats #team" + bob <## "support: unread: 3, require attention: 0, mentions: 0" + + aliceMentionedByBobItemId <- lastItemId alice + + bob ##> "/_send #1(_support) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@dan 8\"}, \"mentions\": {\"dan\": 4}}]" + bob <# "#team (support) @dan 8" + alice <# "#team (support: bob) bob> @dan 8" + dan <# "#team (support: bob) bob!> @dan 8" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 5, require attention: 2, mentions: 2" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" + bob ##> "/member support chats #team" + bob <## "support: unread: 3, require attention: 0, mentions: 0" + + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "ok") + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1" + + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "ok") + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" + + dan ##> "/_send #1(_support:3) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@bob 9\"}, \"mentions\": {\"bob\": 3}}]" + dan <# "#team (support: bob) @bob 9" + alice <# "#team (support: bob) dan> @bob 9" + bob <# "#team (support) dan!> @bob 9" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 0, mentions: 0" + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" + bob ##> "/member support chats #team" + bob <## "support: unread: 4, require attention: 0, mentions: 1" + + alice #$> ("/_read chat #1(_support:2)", id, "ok") + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + dan #$> ("/_read chat #1(_support:3)", id, "ok") + + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" + + bob #$> ("/_read chat #1(_support)", id, "ok") + + bob ##> "/member support chats #team" + bob <## "support: unread: 0, require attention: 0, mentions: 0" + + cath ##> "/member support chats #team" + cath Date: Mon, 7 Apr 2025 07:55:56 +0000 Subject: [PATCH 05/72] core: group knocking - acceptance to group chat item (#5810) --- src/Simplex/Chat/Library/Subscriber.hs | 13 ++++++++++--- src/Simplex/Chat/Messages/CIContent.hs | 4 ++++ src/Simplex/Chat/Messages/CIContent/Events.hs | 2 ++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e02b046a33..aff023380b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -875,7 +875,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName XInfo p -> xInfoMember gInfo' m'' p brokerTs XGrpLinkMem p -> xGrpLinkMem gInfo' m'' conn' p - XGrpLinkAcpt role memberId -> xGrpLinkAcpt gInfo' m'' role memberId + XGrpLinkAcpt role memberId -> xGrpLinkAcpt gInfo' m'' role memberId msg brokerTs XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> xGrpMemInv gInfo' m'' memId introInv @@ -2086,8 +2086,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatchingMemberContact m' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" - xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> MemberId -> CM () - xGrpLinkAcpt gInfo@GroupInfo {membership} m role memberId + xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () + xGrpLinkAcpt gInfo@GroupInfo {membership} m role memberId msg brokerTs | sameMemberId memberId membership = processUserAccepted | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case @@ -2095,6 +2095,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right referencedMember -> do referencedMember' <- withFastStore' $ \db -> updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Just referencedMember'} + gEvent = RGEMemberAccepted (groupMemberId' referencedMember') (fromLocalProfile $ memberProfile referencedMember') + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView gInfo scopeInfo ci toView $ CRMemberAcceptedByOther user gInfo m referencedMember' where newMemberStatus refMem = case memberConn refMem of @@ -2103,6 +2107,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where processUserAccepted = do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) + groupMsgToView gInfo scopeInfo ci toView $ CRUserJoinedGroup user gInfo {membership = membership'} m let cd = CDGroupRcv gInfo Nothing m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 60d5464b79..886639aa07 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -206,6 +206,8 @@ ciRequiresAttention content = case msgDirection @d of CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False + RGEMemberAccepted {} -> False + RGEUserAccepted -> False RGEMemberLeft -> False RGEMemberRole {} -> False RGEMemberBlocked {} -> False @@ -317,6 +319,8 @@ rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case RGEMemberAdded _ p -> "added " <> profileToText p RGEMemberConnected -> "connected" + RGEMemberAccepted _ p -> "accepted " <> profileToText p + RGEUserAccepted -> "accepted you" RGEMemberLeft -> "left" RGEMemberRole _ p r -> "changed role of " <> profileToText p <> " to " <> safeDecodeUtf8 (strEncode r) RGEMemberBlocked _ p blocked -> (if blocked then "blocked" else "unblocked") <> " " <> profileToText p diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 054530e06f..ca076e5b8a 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -15,6 +15,8 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) data RcvGroupEvent = RGEMemberAdded {groupMemberId :: GroupMemberId, profile :: Profile} -- CRJoinedGroupMemberConnecting | RGEMemberConnected -- CRUserJoinedGroup, CRJoinedGroupMember, CRConnectedToGroupMember + | RGEMemberAccepted {groupMemberId :: GroupMemberId, profile :: Profile} + | RGEUserAccepted | RGEMemberLeft -- CRLeftMember | RGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} | RGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMemberBlockedForAll From 805a69da94d7057c119e731b8a983b4f8add5de3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 21 Apr 2025 15:17:21 +0000 Subject: [PATCH 06/72] android, desktop: group knocking (#5816) --- .../platform/PlatformTextField.android.kt | 3 + .../chat/simplex/common/model/ChatModel.kt | 342 ++++--- .../chat/simplex/common/model/SimpleXAPI.kt | 180 ++-- .../common/platform/PlatformTextField.kt | 1 + .../chat/simplex/common/views/TerminalView.kt | 1 + .../simplex/common/views/chat/ChatInfoView.kt | 10 +- .../common/views/chat/ChatItemsLoader.kt | 6 +- .../simplex/common/views/chat/ChatView.kt | 898 +++++++++--------- .../ComposeContextPendingMemberActionsView.kt | 116 +++ .../simplex/common/views/chat/ComposeView.kt | 38 +- .../views/chat/SelectableChatItemToolbars.kt | 2 +- .../simplex/common/views/chat/SendMsgView.kt | 7 +- .../views/chat/group/AddGroupMembersView.kt | 2 +- .../views/chat/group/GroupChatInfoView.kt | 132 ++- .../views/chat/group/GroupMemberInfoView.kt | 30 + .../views/chat/group/GroupMembersToolbar.kt | 2 +- .../common/views/chat/group/GroupMentions.kt | 20 + .../views/chat/group/GroupPreferences.kt | 26 + .../views/chat/group/GroupReportsView.kt | 4 +- .../views/chat/group/MemberAdmission.kt | 152 +++ .../views/chat/group/MemberSupportChatView.kt | 122 +++ .../views/chat/group/MemberSupportView.kt | 260 +++++ .../common/views/chat/item/ChatItemView.kt | 5 +- .../views/chatlist/ChatListNavLinkView.kt | 9 +- .../commonMain/resources/MR/base/strings.xml | 29 +- .../platform/PlatformTextField.desktop.kt | 3 + src/Simplex/Chat/Library/Commands.hs | 6 +- src/Simplex/Chat/Library/Internal.hs | 26 +- src/Simplex/Chat/Library/Subscriber.hs | 36 +- src/Simplex/Chat/Store/Groups.hs | 18 +- src/Simplex/Chat/Store/Messages.hs | 41 +- .../SQLite/Migrations/chat_query_plans.txt | 58 +- src/Simplex/Chat/Types.hs | 2 +- tests/ChatTests/Groups.hs | 5 + 34 files changed, 1844 insertions(+), 748 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 54e437afb1..e6038a3fd5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -55,6 +55,7 @@ actual fun PlatformTextField( textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, + userIsPending: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, @@ -198,6 +199,8 @@ actual fun PlatformTextField( } if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) + } else if (userIsPending) { + ComposeOverlay(MR.strings.reviewed_by_moderators, textStyle, padding) } else if (userIsObserver) { ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) } 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 7e5eed3c42..76f984a396 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 @@ -294,7 +294,7 @@ object ChatModel { } } - class ChatsContext(val contentTag: MsgContentTag?) { + class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) /** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on. * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. @@ -309,6 +309,20 @@ object ChatModel { fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } + val contentTag: MsgContentTag? = + when (secondaryContextFilter) { + null -> null + is SecondaryContextFilter.GroupChatScopeContext -> null + is SecondaryContextFilter.MsgContentTagContext -> secondaryContextFilter.contentTag + } + + val groupScopeInfo: GroupChatScopeInfo? = + when (secondaryContextFilter) { + null -> null + is SecondaryContextFilter.GroupChatScopeContext -> secondaryContextFilter.groupScopeInfo + is SecondaryContextFilter.MsgContentTagContext -> null + } + suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0) @@ -363,7 +377,7 @@ object ChatModel { updateContact(rhId, updatedContact) } - suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) + suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null)) // TODO [knocking] review private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { if (hasChat(rhId, cInfo.id)) { @@ -415,55 +429,58 @@ object ChatModel { } suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { - // mark chat non deleted - if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { - val updatedContact = cInfo.contact.copy(chatDeleted = false) - updateContact(rhId, updatedContact) - } - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - val newPreviewItem = when (cInfo) { - is ChatInfo.Group -> { - val currentPreviewItem = chat.chatItems.firstOrNull() - if (currentPreviewItem != null) { - if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { - cItem + // update chat list + if (cInfo.groupChatScope() == null) { + // mark chat non deleted + if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { + val updatedContact = cInfo.contact.copy(chatDeleted = false) + updateContact(rhId, updatedContact) + } + // update preview + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val newPreviewItem = when (cInfo) { + is ChatInfo.Group -> { + val currentPreviewItem = chat.chatItems.firstOrNull() + if (currentPreviewItem != null) { + if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { + cItem + } else { + currentPreviewItem + } } else { - currentPreviewItem + cItem } - } else { - cItem } - } - else -> cItem - } - val wasUnread = chat.unreadTag - chats[i] = chat.copy( - chatItems = arrayListOf(newPreviewItem), - chatStats = - if (cItem.meta.itemStatus is CIStatus.RcvNew) { - increaseUnreadCounter(rhId, currentUser.value!!) - chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) - } - else - chat.chatStats - ) - updateChatTagReadNoContentTag(chats[i], wasUnread) - if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chats[i], 0) + else -> cItem + } + val wasUnread = chat.unreadTag + chats[i] = chat.copy( + chatItems = arrayListOf(newPreviewItem), + chatStats = + if (cItem.meta.itemStatus is CIStatus.RcvNew) { + increaseUnreadCounter(rhId, currentUser.value!!) + chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) + } else + chat.chatStats + ) + updateChatTagReadInPrimaryContext(chats[i], wasUnread) + + if (appPlatform.isDesktop && cItem.chatDir.sent) { + reorderChat(chats[i], 0) + } else { + popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) + } } else { - popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } + // add to current scope withContext(Dispatchers.Main) { - // add to current chat - if (chatId.value == cInfo.id) { + if (chatItemBelongsToScope(cInfo, cItem)) { // Prevent situation when chat item already in the list received from backend if (chatItems.value.none { it.id == cItem.id }) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { @@ -475,30 +492,47 @@ object ChatModel { } } } - - suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - val res: Boolean - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(cItem)) - if (pItem.isRcvNew && !cItem.isRcvNew) { - // status changed from New to Read, update counter - decreaseCounterInChatNoContentTag(rhId, cInfo.id) + + private fun chatItemBelongsToScope(cInfo: ChatInfo, cItem: ChatItem): Boolean = + when (secondaryContextFilter) { + null -> + chatId.value == cInfo.id && cInfo.groupChatScope() == null + is SecondaryContextFilter.GroupChatScopeContext -> { + val cInfoScope = cInfo.groupChatScope() + if (cInfoScope != null) { + chatId.value == cInfo.id && sameChatScope(cInfoScope, secondaryContextFilter.groupScopeInfo.toChatScope()) + } else { + false } } - res = false - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) - res = true + is SecondaryContextFilter.MsgContentTagContext -> + chatId.value == cInfo.id && cItem.isReport } - return withContext(Dispatchers.Main) { - // update current chat - if (chatId.value == cInfo.id) { + + suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { + var itemAdded = false + // update chat list + if (cInfo.groupChatScope() == null) { + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(cItem)) + if (pItem.isRcvNew && !cItem.isRcvNew) { + // status changed from New to Read, update counter + decreaseCounterInPrimaryContext(rhId, cInfo.id) + } + } + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + itemAdded = true + } + } + // update current scope + withContext(Dispatchers.Main) { + if (chatItemBelongsToScope(cInfo, cItem)) { if (cItem.isDeletedContent || cItem.meta.itemDeleted != null) { AudioPlayer.stop(cItem) } @@ -506,7 +540,6 @@ object ChatModel { val itemIndex = items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { items[itemIndex] = cItem - false } else { val status = chatItemStatuses.remove(cItem.id) val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { @@ -515,44 +548,52 @@ object ChatModel { cItem } addToChatItems(ci) - true + itemAdded = true } - } else { - res } } + return itemAdded } suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) { + // TODO [knocking] see diff: + // why chatItemStatuses gets updated even if chatId doesn't match? + // should chatItemBelongsToScope replace chatId check? + // should chatItemStatuses be updated only if chatItemBelongsToScope (as in changed code)? this seems to make more sense withContext(Dispatchers.Main) { - if (chatId.value == cInfo.id) { + if (chatItemBelongsToScope(cInfo, cItem)) { val items = chatItems.value val itemIndex = atIndex ?: items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { items[itemIndex] = cItem } - } else if (status != null) { - chatItemStatuses[cItem.id] = status + if (status != null) { + chatItemStatuses[cItem.id] = status + } } } } + // TODO [knocking] why does this function not use `withContext(Dispatchers.Main) { ... }` ? fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { - if (cItem.isRcvNew) { - decreaseCounterInChatNoContentTag(rhId, cInfo.id) - } - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) + // update chat list + if (cInfo.groupChatScope() == null) { + if (cItem.isRcvNew) { + decreaseCounterInPrimaryContext(rhId, cInfo.id) + } + // update preview + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) + } } } - // remove from current chat - if (chatId.value == cInfo.id) { + // remove from current scope + if (chatItemBelongsToScope(cInfo, cItem)) { chatItems.removeAllAndNotify { // We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted // (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id) @@ -585,7 +626,7 @@ object ChatModel { Log.d(TAG, "exiting removeMemberItems") return } - val cInfo = ChatInfo.Group(groupInfo) + val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review if (chatId.value == groupInfo.id) { for (i in 0 until chatItems.value.size) { val updatedItem = removedUpdatedItem(chatItems.value[i]) @@ -687,7 +728,7 @@ object ChatModel { chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions) ) - updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) + updateChatTagReadInPrimaryContext(chats[chatIdx], wasUnread) } } } @@ -728,9 +769,9 @@ object ChatModel { return markedRead to mentionsMarkedRead } - private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { + private fun decreaseCounterInPrimaryContext(rhId: Long?, chatId: ChatId) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return @@ -744,7 +785,7 @@ object ChatModel { unreadCount = unreadCount, ) ) - updateChatTagReadNoContentTag(chats[chatIndex], wasUnread) + updateChatTagReadInPrimaryContext(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { @@ -813,16 +854,16 @@ object ChatModel { } fun increaseUnreadCounter(rhId: Long?, user: UserLike) { - changeUnreadCounterNoContentTag(rhId, user, 1) + changeUnreadCounterInPrimaryContext(rhId, user, 1) } fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { - changeUnreadCounterNoContentTag(rhId, user, -by) + changeUnreadCounterInPrimaryContext(rhId, user, -by) } - private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) { + private fun changeUnreadCounterInPrimaryContext(rhId: Long?, user: UserLike, by: Int) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } if (i != -1) { @@ -830,9 +871,9 @@ object ChatModel { } } - fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) { + fun updateChatTagReadInPrimaryContext(chat: Chat, wasUnread: Boolean) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val tags = chat.chatInfo.chatTags ?: return val nowUnread = chat.unreadTag @@ -842,21 +883,21 @@ object ChatModel { unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 } } else if (!nowUnread && wasUnread) { - markChatTagReadNoContentTag_(chat, tags) + markChatTagReadInPrimaryContext_(chat, tags) } } fun markChatTagRead(chat: Chat) { if (chat.unreadTag) { chat.chatInfo.chatTags?.let { tags -> - markChatTagReadNoContentTag_(chat, tags) + markChatTagReadInPrimaryContext_(chat, tags) } } } - private fun markChatTagReadNoContentTag_(chat: Chat, tags: List) { + private fun markChatTagReadInPrimaryContext_(chat: Chat, tags: List) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return for (tag in tags) { val count = unreadTags[tag] @@ -888,12 +929,12 @@ object ChatModel { val wasReportsCount = chat.chatStats.reportsCount val nowReportsCount = chats[i].chatStats.reportsCount val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0 - changeGroupReportsTagNoContentTag(by) + changeGroupReportsTagInPrimaryContext(by) } } - private fun changeGroupReportsTagNoContentTag(by: Int = 0) { - if (by == 0 || contentTag != null) return + private fun changeGroupReportsTagInPrimaryContext(by: Int = 0) { + if (by == 0 || secondaryContextFilter != null) return presetTags[PresetTagKind.GROUP_REPORTS] = kotlin.math.max(0, (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by) clearActiveChatFilterIfNeeded() } @@ -1098,6 +1139,28 @@ enum class ChatType(val type: String) { ContactConnection(":"); } +sealed class GroupChatScope { + class MemberSupport(val groupMemberId_: Long?): GroupChatScope() +} + +fun sameChatScope(scope1: GroupChatScope, scope2: GroupChatScope) = + scope1 is GroupChatScope.MemberSupport + && scope2 is GroupChatScope.MemberSupport + && scope1.groupMemberId_ == scope2.groupMemberId_ + +@Serializable +sealed class GroupChatScopeInfo { + @Serializable @SerialName("memberSupport") data class MemberSupport(val groupMember_: GroupMember?) : GroupChatScopeInfo() + + fun toChatScope(): GroupChatScope = + when (this) { + is MemberSupport -> when (groupMember_) { + null -> GroupChatScope.MemberSupport(groupMemberId_ = null) + else -> GroupChatScope.MemberSupport(groupMemberId_ = groupMember_.groupMemberId) + } + } +} + @Serializable data class User( val remoteHostId: Long?, @@ -1235,6 +1298,11 @@ data class Chat( else -> false } + val userIsPending: Boolean get() = when(chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.membership.memberPending + else -> false + } + val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) { All -> chatStats.unreadChat || chatStats.unreadCount > 0 Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0 @@ -1299,7 +1367,7 @@ sealed class ChatInfo: SomeChat, NamedChat { } @Serializable @SerialName("group") - data class Group(val groupInfo: GroupInfo): ChatInfo() { + data class Group(val groupInfo: GroupInfo, val groupChatScope: GroupChatScopeInfo?): ChatInfo() { override val chatType get() = ChatType.Group override val localDisplayName get() = groupInfo.localDisplayName override val id get() = groupInfo.id @@ -1318,7 +1386,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val localAlias get() = groupInfo.localAlias companion object { - val sampleData = Group(GroupInfo.sampleData) + val sampleData = Group(GroupInfo.sampleData, groupChatScope = null) } } @@ -1419,6 +1487,11 @@ sealed class ChatInfo: SomeChat, NamedChat { } } + fun groupChatScope(): GroupChatScope? = when (this) { + is Group -> groupChatScope?.toChatScope() + else -> null + } + fun ntfsEnabled(ci: ChatItem): Boolean = ntfsEnabled(ci.meta.userMention) @@ -1449,7 +1522,9 @@ sealed class ChatInfo: SomeChat, NamedChat { val userCanSend: Boolean get() = when (this) { is ChatInfo.Direct -> true - is ChatInfo.Group -> groupInfo.membership.memberRole >= GroupMemberRole.Member + is ChatInfo.Group -> + (groupInfo.membership.memberRole >= GroupMemberRole.Member && !groupInfo.membership.memberPending) + || groupChatScope != null is ChatInfo.Local -> true else -> false } @@ -1834,7 +1909,8 @@ data class GroupProfile ( val description: String? = null, override val image: String? = null, override val localAlias: String = "", - val groupPreferences: GroupPreferences? = null + val groupPreferences: GroupPreferences? = null, + val memberAdmission: GroupMemberAdmission? = null ): NamedChat { companion object { val sampleData = GroupProfile( @@ -1844,6 +1920,27 @@ data class GroupProfile ( } } +@Serializable +data class GroupMemberAdmission( + val review: MemberCriteria? = null, +) { + companion object { + val sampleData = GroupMemberAdmission( + review = null, + ) + } +} + +@Serializable +enum class MemberCriteria { + @SerialName("all") All; + + val text: String + get() = when(this) { + MemberCriteria.All -> generalGetString(MR.strings.member_criteria_all) + } +} + @Serializable data class BusinessChatInfo ( val chatType: BusinessChatType, @@ -1872,7 +1969,8 @@ data class GroupMember ( val memberProfile: LocalProfile, val memberContactId: Long? = null, val memberContactProfileId: Long, - var activeConn: Connection? = null + var activeConn: Connection? = null, + val supportChat: GroupSupportChat? = null ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -1931,6 +2029,7 @@ data class GroupMember ( GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false GroupMemberStatus.MemPendingApproval -> true + GroupMemberStatus.MemPendingReview -> true GroupMemberStatus.MemIntroduced -> false GroupMemberStatus.MemIntroInvited -> false GroupMemberStatus.MemAccepted -> false @@ -1948,6 +2047,7 @@ data class GroupMember ( GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false GroupMemberStatus.MemPendingApproval -> false + GroupMemberStatus.MemPendingReview -> false GroupMemberStatus.MemIntroduced -> true GroupMemberStatus.MemIntroInvited -> true GroupMemberStatus.MemAccepted -> true @@ -1957,6 +2057,12 @@ data class GroupMember ( GroupMemberStatus.MemCreator -> true } + val memberPending: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemPendingApproval -> true + GroupMemberStatus.MemPendingReview -> true + else -> false + } + fun canBeRemoved(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft @@ -1997,6 +2103,14 @@ data class GroupMember ( } } +@Serializable +class GroupSupportChat ( + val chatTs: Instant, + val unread: Int, + val memberAttention: Int, + val mentions: Int +) + @Serializable data class GroupMemberSettings(val showMessages: Boolean) {} @@ -2053,6 +2167,7 @@ enum class GroupMemberStatus { @SerialName("unknown") MemUnknown, @SerialName("invited") MemInvited, @SerialName("pending_approval") MemPendingApproval, + @SerialName("pending_review") MemPendingReview, @SerialName("introduced") MemIntroduced, @SerialName("intro-inv") MemIntroInvited, @SerialName("accepted") MemAccepted, @@ -2069,6 +2184,7 @@ enum class GroupMemberStatus { MemUnknown -> generalGetString(MR.strings.group_member_status_unknown) MemInvited -> generalGetString(MR.strings.group_member_status_invited) MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval) + MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review) MemIntroduced -> generalGetString(MR.strings.group_member_status_introduced) MemIntroInvited -> generalGetString(MR.strings.group_member_status_intro_invitation) MemAccepted -> generalGetString(MR.strings.group_member_status_accepted) @@ -2086,6 +2202,7 @@ enum class GroupMemberStatus { MemUnknown -> generalGetString(MR.strings.group_member_status_unknown_short) MemInvited -> generalGetString(MR.strings.group_member_status_invited) MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval_short) + MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review_short) MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting) MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting) MemAccepted -> generalGetString(MR.strings.group_member_status_connecting) @@ -2508,6 +2625,8 @@ data class ChatItem ( is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberConnected -> false + is RcvGroupEvent.MemberAccepted -> false + is RcvGroupEvent.UserAccepted -> false is RcvGroupEvent.MemberLeft -> false is RcvGroupEvent.MemberRole -> false is RcvGroupEvent.MemberBlocked -> false @@ -2700,6 +2819,11 @@ data class ChatItem ( } } +sealed class SecondaryContextFilter { + class GroupChatScopeContext(val groupScopeInfo: GroupChatScopeInfo): SecondaryContextFilter() + class MsgContentTagContext(val contentTag: MsgContentTag): SecondaryContextFilter() +} + fun MutableState>.add(index: Int, elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(index, elem) } } @@ -4133,6 +4257,8 @@ sealed class RcvDirectEvent() { sealed class RcvGroupEvent() { @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() @Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() + @Serializable @SerialName("userAccepted") class UserAccepted(): RcvGroupEvent() @Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent() @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent() @Serializable @SerialName("memberBlocked") class MemberBlocked(val groupMemberId: Long, val profile: Profile, val blocked: Boolean): RcvGroupEvent() @@ -4148,6 +4274,8 @@ sealed class RcvGroupEvent() { val text: String get() = when (this) { is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName) is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected) + is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName) + is UserAccepted -> generalGetString(MR.strings.rcv_group_event_user_accepted) is MemberLeft -> generalGetString(MR.strings.rcv_group_event_member_left) is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text) is MemberBlocked -> if (blocked) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index a557cf93cf..90f8259fb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -903,8 +903,8 @@ object ChatController { return null } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { - val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, pagination, search)) + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, scope, contentTag, pagination, search)) if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) { @@ -937,8 +937,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -996,8 +996,8 @@ object ChatController { } } - suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { - return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): ChatItemInfo? { + return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, scope, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo else -> { apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) @@ -1006,13 +1006,13 @@ object ChatController { } } - suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemIds: List, ttl: Int?): List? { - val cmd = CC.ApiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl) + suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { + val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl) return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } - suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, chatItemIds: List): CR.ForwardPlan? { - return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds))) { + suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, chatItemIds: List): CR.ForwardPlan? { + return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, fromScope, chatItemIds))) { is CR.ForwardPlan -> r else -> { apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r) @@ -1021,8 +1021,8 @@ object ChatController { } } - suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? { - val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, updatedMessage, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, scope, itemId, updatedMessage, live)) when { r is CR.ChatItemUpdated -> return r.chatItem r is CR.ChatItemNotChanged -> return r.chatItem @@ -1044,8 +1044,8 @@ object ChatController { return null } - suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { - val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, itemId, add, reaction)) + suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { + val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, scope, itemId, add, reaction)) if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null @@ -1059,8 +1059,8 @@ object ChatController { return null } - suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, itemIds: List, mode: CIDeleteMode): List? { - val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, itemIds, mode)) + suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List, mode: CIDeleteMode): List? { + val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode)) if (r is CR.ChatItemsDeleted) return r.chatItemDeletions Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null @@ -1761,15 +1761,15 @@ object ChatController { return null } - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { - val r = sendCmd(rh, CC.ApiChatRead(type, id)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List): Boolean { - val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds)) + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): Boolean { + val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}") return false @@ -2007,6 +2007,19 @@ object ChatController { } } + suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): GroupMember? { + val r = sendCmd(rh, CC.ApiAcceptMember(groupId, groupMemberId, memberRole)) + return when (r) { + is CR.MemberAccepted -> r.member + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAcceptMember", generalGetString(MR.strings.error_accepting_member), r) + } + null + } + } + } + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): List? = when (val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages))) { is CR.UserDeletedMembers -> r.members @@ -2543,14 +2556,13 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.addChatItem(rhId, cInfo, cItem) + // TODO [knocking] increase support chats unread count; move `isActiveReport` checks inside model functions? if (cItem.isActiveReport) { chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) - } + chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) } } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { withContext(Dispatchers.Main) { @@ -2579,9 +2591,7 @@ object ChatController { chatModel.chatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) - } + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } } } @@ -2593,9 +2603,7 @@ object ChatController { chatModel.chatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } withContext(Dispatchers.Main) { - if (r.reaction.chatReaction.chatItem.isReport) { - chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) - } + chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } } } @@ -2638,12 +2646,10 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (cItem.isReport) { - if (toChatItem == null) { - chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem) - } else { - chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem) - } + if (toChatItem == null) { + chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem) + } else { + chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } } @@ -2703,6 +2709,13 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } + is CR.MemberAcceptedByOther -> + if (active(r.user)) { + // TODO [knocking] update secondary context? + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + } + } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { withContext(Dispatchers.Main) { @@ -2783,6 +2796,16 @@ object ChatController { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } + if ( + chatModel.chatId.value == r.groupInfo.id + && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) + && chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + ) { + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value = null + } + ModalManager.end.closeModals() + } } is CR.JoinedGroupMember -> if (active(r.user)) { @@ -3143,11 +3166,11 @@ object ChatController { if (activeUser(rh, user)) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem - withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) - } + chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) + } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) } } } @@ -3159,7 +3182,7 @@ object ChatController { chatModel.users.addAll(users) return } - val cInfo = ChatInfo.Group(r.groupInfo) + val cInfo = ChatInfo.Group(r.groupInfo, groupChatScope = null) // TODO [knocking] get scope from items? withContext(Dispatchers.Main) { val chatsCtx = chatModel.chatsContext r.chatItemIDs.forEach { itemId -> @@ -3214,11 +3237,11 @@ object ChatController { if (!activeUser(rh, user)) { notify() } else { - val createdChat = withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } + val createdChat = withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) + } withContext(Dispatchers.Main) { - if (cItem.content.msgContent is MsgContent.MCReport) { - chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) - } + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) } if (createdChat) { notify() @@ -3423,9 +3446,9 @@ sealed class CC { class ApiGetSettings(val settings: AppSettings): CC() class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() - class ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() - class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() + class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3433,18 +3456,19 @@ sealed class CC { class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() - class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC() - class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() + class ApiUpdateChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC() + class ApiDeleteChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() class ApiArchiveReceivedReports(val groupId: Long): CC() class ApiDeleteReceivedReports(val groupId: Long, val itemIds: List, val mode: CIDeleteMode): CC() - class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() + class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() - class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val chatItemIds: List): CC() - class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List, val ttl: Int?): CC() + class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() + class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() + class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() class ApiMembersRole(val groupId: Long, val memberIds: List, val memberRole: GroupMemberRole): CC() class ApiBlockMembersForAll(val groupId: Long, val memberIds: List, val blocked: Boolean): CC() class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean): CC() @@ -3522,8 +3546,8 @@ sealed class CC { class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() - class ApiChatRead(val type: ChatType, val id: Long): CC() - class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List): CC() + class ApiChatRead(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() + class ApiChatItemsRead(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() @@ -3595,16 +3619,16 @@ sealed class CC { } else { " content=${contentTag.name.lowercase()}" } - "/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + "/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") } - is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" + is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" - is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}" + is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" is ApiDeleteChatTag -> "/_delete tag $tagId" is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}" is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}" @@ -3613,23 +3637,24 @@ sealed class CC { "/_create *$noteFolderId json $msgs" } is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" - is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}" - is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" + is ApiUpdateChatItem -> "/_update item ${chatRef(type, id, scope)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}" + is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id, scope)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" is ApiArchiveReceivedReports -> "/_archive reports #$groupId" is ApiDeleteReceivedReports -> "/_delete reports #$groupId ${itemIds.joinToString(",")} ${mode.deleteMode}" - is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" + is ApiChatItemReaction -> "/_reaction ${chatRef(type, id, scope)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { val ttlStr = if (ttl != null) "$ttl" else "default" - "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + "/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } is ApiPlanForwardChatItems -> { - "/_forward plan ${chatRef(fromChatType, fromChatId)} ${chatItemIds.joinToString(",")}" + "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" + is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}" @@ -3653,13 +3678,13 @@ sealed class CC { is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" - is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id)} ${chatItemTTLStr(seconds)}" + is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id, scope = null)} ${chatItemTTLStr(seconds)}" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}" is ReconnectServer -> "/reconnect $userId $server" is ReconnectAllServers -> "/reconnect" - is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" + is APISetChatSettings -> "/_settings ${chatRef(type, id, scope = null)} ${json.encodeToString(chatSettings)}" is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" @@ -3681,8 +3706,8 @@ sealed class CC { is APIConnectPlan -> "/_connect plan $userId $connLink" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" - is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}" - is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" + is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}" + is ApiClearChat -> "/_clear chat ${chatRef(type, id, scope = null)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" @@ -3707,9 +3732,9 @@ sealed class CC { is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiGetNetworkStatuses -> "/_network_statuses" - is ApiChatRead -> "/_read chat ${chatRef(type, id)}" - is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}" - is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" + is ApiChatRead -> "/_read chat ${chatRef(type, id, scope)}" + is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id, scope)} ${itemIds.joinToString(",")}" + is ApiChatUnread -> "/_unread chat ${chatRef(type, id, scope = null)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId" + (" approved_relays=${onOff(userApprovedRelays)}") + @@ -3793,6 +3818,7 @@ sealed class CC { is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" + is ApiAcceptMember -> "apiAcceptMember" is ApiMembersRole -> "apiMembersRole" is ApiBlockMembersForAll -> "apiBlockMembersForAll" is ApiRemoveMembers -> "apiRemoveMembers" @@ -3929,7 +3955,13 @@ sealed class CC { private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd) companion object { - fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" + fun chatRef(chatType: ChatType, id: Long, scope: GroupChatScope?) = when (scope) { + null -> "${chatType.type}${id}" + is GroupChatScope.MemberSupport -> when (scope.groupMemberId_) { + null -> "${chatType.type}${id}(_support)" + else -> "${chatType.type}${id}(_support:${scope.groupMemberId_})" + } + } } } @@ -5846,6 +5878,8 @@ sealed class CR { @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @Serializable @SerialName("membersRoleUser") class MembersRoleUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val toRole: GroupMemberRole): CR() @Serializable @SerialName("memberBlockedForAll") class MemberBlockedForAll(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val blocked: Boolean): CR() @@ -6031,6 +6065,8 @@ sealed class CR { is ReceivedGroupInvitation -> "receivedGroupInvitation" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" + is MemberAccepted -> "memberAccepted" + is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" is MembersRoleUser -> "membersRoleUser" is MemberBlockedForAll -> "memberBlockedForAll" @@ -6209,6 +6245,8 @@ sealed class CR { is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") + is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole") is MemberBlockedForAll -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nblocked: $blocked") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 1daf5a7ba7..3502d7049c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -16,6 +16,7 @@ expect fun PlatformTextField( textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, + userIsPending: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 22b013ff60..66347c5ba8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -104,6 +104,7 @@ fun TerminalLayout( needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, placeholder = "", 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 2a77d0a6dc..a8b77e8fdd 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 @@ -40,9 +40,8 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* -import chat.simplex.common.views.chat.group.ChatTTLSection +import chat.simplex.common.views.chat.group.ChatTTLOption import chat.simplex.common.views.chatlist.updateChatSettings -import chat.simplex.common.views.database.* import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -617,7 +616,10 @@ fun ChatInfoLayout( } SectionDividerSpaced(maxBottomPadding = false) - ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) + SectionView { + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + } SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) val conn = contact.activeConn @@ -1384,7 +1386,7 @@ private fun setChatTTL( private suspend fun afterSetChatTTL(chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState) { try { val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) - val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, null, pagination) ?: return + val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, scope = null, contentTag = null, pagination) ?: return if (chat.chatItems.isEmpty()) { // replacing old chat with the same old chat but without items. Less intrusive way of clearing a preview withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index eabe9cb60a..f6f5b6fcb7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -17,7 +17,7 @@ suspend fun apiLoadSingleMessage( apiId: Long, itemId: Long ): ChatItem? = coroutineScope { - val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null + val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null chat.chatItems.firstOrNull() } @@ -31,7 +31,7 @@ suspend fun apiLoadMessages( openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, pagination, search) ?: return@coroutineScope // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes /** When [openAroundItemId] is provided, chatId can be different too */ if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) @@ -54,7 +54,7 @@ suspend fun processLoadedChat( when (pagination) { is ChatPagination.Initial -> { val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList() - if (chatsCtx.contentTag == null) { + if (chatsCtx.secondaryContextFilter == null) { // update main chats, not content tagged withContext(Dispatchers.Main) { val oldChat = chatModel.chatsContext.getChat(chat.id) 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 ef82b9a35b..d3dad36d62 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 @@ -65,7 +65,17 @@ fun ChatView( val showSearch = rememberSaveable { mutableStateOf(false) } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } - val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } } + val activeChatInfo = remember { derivedStateOf { + var chatInfo = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo + if ( + chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + && chatInfo is ChatInfo.Group + ) { + val scopeInfo = chatsCtx.secondaryContextFilter.groupScopeInfo + chatInfo = chatInfo.copy(groupChatScope = scopeInfo) + } + chatInfo + } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value if (chatInfo == null || user == null) { @@ -99,7 +109,7 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> - if (chatsCtx.contentTag == null) { + if (chatsCtx.secondaryContextFilter == null) { markUnreadChatAsRead(chatId) } showSearch.value = false @@ -108,6 +118,18 @@ fun ChatView( } } } + if (chatsCtx.secondaryContextFilter == null && chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberPending) { + LaunchedEffect(Unit) { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(chatInfo.groupInfo, groupChatScope = scopeInfo) + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = mutableStateOf(null), + supportChatInfo, + scopeInfo + ) + } + } val view = LocalMultiplatformView() val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button @@ -133,7 +155,7 @@ fun ChatView( val sameText = searchText.value == value // showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it // (required on Android to have this check to prevent call to search with old text) - val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.contentTag == null + val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null val c = chatModel.getChat(chatInfo.id) if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { @@ -167,7 +189,7 @@ fun ChatView( ) } ComposeView( - chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, + rhId = remoteHostId.value, chatModel, chatsCtx, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }, focusRequester = focusRequester ) @@ -220,6 +242,7 @@ fun ChatView( rh = chatRh, fromChatType = chatInfo.chatType, fromChatId = chatInfo.apiId, + fromScope = chatInfo.groupChatScope(), chatItemIds = chatItemIds ) @@ -343,7 +366,7 @@ fun ChatView( setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - if (chatsCtx.contentTag == null) { + if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(true) { close -> @@ -379,6 +402,7 @@ fun ChatView( chatRh, type = chatInfo.chatType, id = chatInfo.apiId, + scope = chatInfo.groupChatScope(), itemIds = listOf(itemId), mode = mode ) @@ -399,12 +423,10 @@ fun ChatView( } } withContext(Dispatchers.Main) { - if (deletedChatItem.isReport) { - if (toChatItem != null) { - chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, deletedChatItem) - } + if (toChatItem != null) { + chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, deletedChatItem) } } } @@ -512,6 +534,7 @@ fun ChatView( rh = chatRh, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), itemId = cItem.id, add = add, reaction = reaction @@ -521,16 +544,14 @@ fun ChatView( chatModel.chatsContext.updateChatItem(cInfo, updatedCI) } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, updatedCI) - } + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, updatedCI) } } } }, showItemDetails = { cInfo, cItem -> suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { - val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) + val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cInfo.groupChatScope(), cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) @@ -583,6 +604,7 @@ fun ChatView( chatRh, chatInfo.chatType, chatInfo.apiId, + chatInfo.groupChatScope(), itemsIds ) } @@ -599,7 +621,8 @@ fun ChatView( chatModel.controller.apiChatRead( chatRh, chatInfo.chatType, - chatInfo.apiId + chatInfo.apiId, + chatInfo.groupChatScope() ) } withContext(Dispatchers.Main) { @@ -745,7 +768,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.contentTag == null)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -773,6 +796,7 @@ fun ChatLayout( .padding(bottom = composeViewHeight.value) ) { GroupMentions( + chatsCtx = chatsCtx, rhId = remoteHostId, composeState = composeState, composeViewFocusRequester = composeViewFocusRequester, @@ -782,7 +806,7 @@ fun ChatLayout( } } } - if (chatsCtx.contentTag == MsgContentTag.Report) { + if (chatsCtx.contentTag == MsgContentTag.Report) { // TODO [knocking] similar bar for support chats, without archiveItems? Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -833,7 +857,7 @@ fun ChatLayout( } val reportsCount = reportsCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (chatsCtx.contentTag == null && reportsCount > 0) { + if (chatsCtx.secondaryContextFilter == null && reportsCount > 0) { // TODO [knocking] support chats unread count toolbar ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) } else { StatusBarBackground() @@ -841,32 +865,58 @@ fun ChatLayout( } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - if (chatsCtx.contentTag == MsgContentTag.Report) { - if (oneHandUI.value) { - StatusBarBackground() - } - Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - Box { - if (selectedChatItems.value == null) { - GroupReportsAppBar(chatsCtx, { ModalManager.end.closeModal() }, onSearchValueChanged) - } else { - SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) - } - } - } - } else { - Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - Box { - if (selectedChatItems.value == null) { - if (chatInfo != null) { - ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + when (chatsCtx.secondaryContextFilter) { + is SecondaryContextFilter.GroupChatScopeContext -> { + when (chatsCtx.secondaryContextFilter.groupScopeInfo) { + is GroupChatScopeInfo.MemberSupport -> { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + MemberSupportChatAppBar(chatsCtx, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) + } + } } - } else { - SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (chatsCtx.contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { - ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) + } + is SecondaryContextFilter.MsgContentTagContext -> { + when (chatsCtx.secondaryContextFilter.contentTag) { + MsgContentTag.Report -> { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + GroupReportsAppBar(chatsCtx, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) + } + } + } + } + else -> TODO() + } + } + null -> { + Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + if (chatInfo != null) { + ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + } + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) + } + } + if (reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { + ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) + } } } } @@ -900,7 +950,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid && chatsCtx.contentTag == null) { + if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -1198,298 +1248,282 @@ fun BoxScope.ChatItemsList( val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state - if (chatsCtx != null) { - val mergedItems = remember { - derivedStateOf { - MergedItems.create(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) - } + val mergedItems = remember { + derivedStateOf { + MergedItems.create(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) } - val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } - val reportsCount = reportsCount(chatInfo.id) - val topPaddingToContent = topPaddingToContent( - chatView = chatsCtx.contentTag == null, - additionalTopBar = chatsCtx.contentTag == null && reportsCount > 0 - ) - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) - val numberOfBottomAppBars = numberOfBottomAppBars() + } + val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } + val reportsCount = reportsCount(chatInfo.id) + val topPaddingToContent = topPaddingToContent( + chatView = chatsCtx.secondaryContextFilter == null, + additionalTopBar = chatsCtx.secondaryContextFilter == null && reportsCount > 0 // TODO [knocking] && support chats unread count > 0 ? + ) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) + val numberOfBottomAppBars = numberOfBottomAppBars() - /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of - * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears - * */ - val maxHeightForList = rememberUpdatedState( - with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } - ) - val resetListState = remember { mutableStateOf(false) } - remember(chatModel.openAroundItemId.value) { - if (chatModel.openAroundItemId.value != null) { - closeSearch() - resetListState.value = !resetListState.value - } + /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of + * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears + * */ + val maxHeightForList = rememberUpdatedState( + with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } + ) + val resetListState = remember { mutableStateOf(false) } + remember(chatModel.openAroundItemId.value) { + if (chatModel.openAroundItemId.value != null) { + closeSearch() + resetListState.value = !resetListState.value } - val highlightedItems = remember { mutableStateOf(setOf()) } - val hoveredItemId = remember { mutableStateOf(null as Long?) } - val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { - val openAroundItemId = chatModel.openAroundItemId.value - val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } - val reportsState = reportsListState - if (openAroundItemId != null) { - highlightedItems.value += openAroundItemId - chatModel.openAroundItemId.value = null - } - hoveredItemId.value = null - if (reportsState != null) { - reportsListState = null - reportsState - } else if (index <= 0 || !searchValueIsEmpty.value) { - LazyListState(0, 0) - } else { - LazyListState(index + 1, -maxHeightForList.value) - } - }) - SaveReportsStateOnDispose(chatsCtx, listState) - val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } - val loadingMoreItems = remember { mutableStateOf(false) } - val animatedScrollingInProgress = remember { mutableStateOf(false) } - val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } - LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { - if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) - ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } + val highlightedItems = remember { mutableStateOf(setOf()) } + val hoveredItemId = remember { mutableStateOf(null as Long?) } + val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { + val openAroundItemId = chatModel.openAroundItemId.value + val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } + val reportsState = reportsListState + if (openAroundItemId != null) { + highlightedItems.value += openAroundItemId + chatModel.openAroundItemId.value = null } - PreloadItems(chatsCtx, chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> - if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false - loadingMoreItems.value = true - withContext(NonCancellable) { - try { - loadMessages(chatId, pagination) { - visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) - } - } finally { - loadingMoreItems.value = false + hoveredItemId.value = null + if (reportsState != null) { + reportsListState = null + reportsState + } else if (index <= 0 || !searchValueIsEmpty.value) { + LazyListState(0, 0) + } else { + LazyListState(index + 1, -maxHeightForList.value) + } + }) + SaveReportsStateOnDispose(chatsCtx, listState) + val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } + val loadingMoreItems = remember { mutableStateOf(false) } + val animatedScrollingInProgress = remember { mutableStateOf(false) } + val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { + if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) + ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } + PreloadItems(chatsCtx, chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false + loadingMoreItems.value = true + withContext(NonCancellable) { + try { + loadMessages(chatId, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } + } finally { + loadingMoreItems.value = false } - true } - val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) - val chatInfoUpdated = rememberUpdatedState(chatInfo) - val scope = rememberCoroutineScope() - val scrollToItem: (Long) -> Unit = remember { - // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling - if (chatsCtx.contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } - scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) - } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } - if (chatsCtx.contentTag == null) { - LaunchedEffect(Unit) { - snapshotFlow { scrollToItemId.value }.filterNotNull().collect { - if (appPlatform.isAndroid) { - ModalManager.end.closeModals() - } - scrollToItem(it) - scrollToItemId.value = null + true + } + val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) + val chatInfoUpdated = rememberUpdatedState(chatInfo) + val scope = rememberCoroutineScope() + val scrollToItem: (Long) -> Unit = remember { + // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling + if (chatsCtx.contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } + scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + } + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } + if (chatsCtx.secondaryContextFilter == null) { + LaunchedEffect(Unit) { + snapshotFlow { scrollToItemId.value }.filterNotNull().collect { + if (appPlatform.isAndroid) { + ModalManager.end.closeModals() } + scrollToItem(it) + scrollToItemId.value = null } } - SmallScrollOnNewMessage(listState, reversedChatItems) - val finishedInitialComposition = remember { mutableStateOf(false) } - NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) + } + SmallScrollOnNewMessage(listState, reversedChatItems) + val finishedInitialComposition = remember { mutableStateOf(false) } + NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) - DisposableEffectOnGone( - whenGone = { - VideoPlayerHolder.releaseAll() - } - ) + DisposableEffectOnGone( + whenGone = { + VideoPlayerHolder.releaseAll() + } + ) - @Composable - fun ChatViewListItem( - itemAtZeroIndexInWholeList: Boolean, - range: State, - showAvatar: Boolean, - cItem: ChatItem, - itemSeparation: ItemSeparation, - previousItemSeparationLargeGap: Boolean, - revealed: State, - reveal: (Boolean) -> Unit + @Composable + fun ChatViewListItem( + itemAtZeroIndexInWholeList: Boolean, + range: State, + showAvatar: Boolean, + cItem: ChatItem, + itemSeparation: ItemSeparation, + previousItemSeparationLargeGap: Boolean, + revealed: State, + reveal: (Boolean) -> Unit + ) { + val itemScope = rememberCoroutineScope() + CompositionLocalProvider( + // Makes horizontal and vertical scrolling to coexist nicely. + // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view + LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() ) { - val itemScope = rememberCoroutineScope() - CompositionLocalProvider( - // Makes horizontal and vertical scrolling to coexist nicely. - // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view - LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() - ) { - val provider = { - providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> + val provider = { + providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> + itemScope.launch { + listState.value.scrollToItem( + min(reversedChatItems.value.lastIndex, indexInReversed + 1), + -maxHeight.value + ) + } + } + } + + @Composable + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { + tryOrShowError("${cItem.id}ChatItem", error = { + CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) + }) { + val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } + LaunchedEffect(Unit) { + snapshotFlow { highlighted.value } + .distinctUntilChanged() + .filter { it } + .collect { + delay(500) + highlightedItems.value = setOf() + } + } + ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + } + } + + @Composable + fun ChatItemView(cItem: ChatItem, range: State, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean) { + val dismissState = rememberDismissState(initialValue = DismissValue.Default) { + if (it == DismissValue.DismissedToStart) { itemScope.launch { - listState.value.scrollToItem( - min(reversedChatItems.value.lastIndex, indexInReversed + 1), - -maxHeight.value - ) - } - } - } - - @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { - tryOrShowError("${cItem.id}ChatItem", error = { - CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) - }) { - val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } - LaunchedEffect(Unit) { - snapshotFlow { highlighted.value } - .distinctUntilChanged() - .filter { it } - .collect { - delay(500) - highlightedItems.value = setOf() - } - } - ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) - } - } - - @Composable - fun ChatItemView(cItem: ChatItem, range: State, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean) { - val dismissState = rememberDismissState(initialValue = DismissValue.Default) { - if (it == DismissValue.DismissedToStart) { - itemScope.launch { - if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } + if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } } } - false } - val swipeableModifier = SwipeToDismissModifier( - state = dismissState, - directions = setOf(DismissDirection.EndToStart), - swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, - ) - val sent = cItem.chatDir.sent + false + } + val swipeableModifier = SwipeToDismissModifier( + state = dismissState, + directions = setOf(DismissDirection.EndToStart), + swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, + ) + val sent = cItem.chatDir.sent - @Composable - fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { - Box( - modifier = modifier.padding( - bottom = if (itemSeparation.largeGap) { - if (itemAtZeroIndexInWholeList) { - 8.dp - } else { - 4.dp - } - } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp - ), - contentAlignment = Alignment.CenterStart - ) { - content() - } + @Composable + fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { + Box( + modifier = modifier.padding( + bottom = if (itemSeparation.largeGap) { + if (itemAtZeroIndexInWholeList) { + 8.dp + } else { + 4.dp + } + } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp + ), + contentAlignment = Alignment.CenterStart + ) { + content() } + } - @Composable - fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + @Composable + fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp { + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp) - } + return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp) + } + + Box { + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null + val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf + val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) + val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } + if (chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { + if (showAvatar) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .fillMaxWidth() + .then(swipeableModifier), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + @Composable + fun MemberNameAndRole(range: State) { + Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + val member = cItem.chatDir.groupMember + val rangeValue = range.value + val (prevMember, memCount) = + if (rangeValue != null) { + chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) + } else { + null to 1 + } + Text( + memberNames(member, prevMember, memCount), + Modifier + .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) + .weight(1f, false), + fontSize = 13.5.sp, + color = MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - Box { - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null - val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf - val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) - val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } - if (chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - if (showAvatar) { - Column( - Modifier - .padding(top = 8.dp) - .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) - .fillMaxWidth() - .then(swipeableModifier), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.Start - ) { - @Composable - fun MemberNameAndRole(range: State) { - Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { - val member = cItem.chatDir.groupMember - val rangeValue = range.value - val (prevMember, memCount) = - if (rangeValue != null) { - chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) - } else { - null to 1 - } Text( - memberNames(member, prevMember, memCount), - Modifier - .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) - .weight(1f, false), + member.memberRole.text, + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, color = MaterialTheme.colors.secondary, - overflow = TextOverflow.Ellipsis, maxLines = 1 ) - if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - - Text( - member.memberRole.text, - Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), - fontSize = 13.5.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colors.secondary, - maxLines = 1 - ) - } } } - - @Composable - fun Item() { - ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { - androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedListItem(Modifier, cItem.id, selectedChatItems) - } - Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { - val member = cItem.chatDir.groupMember - Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { - MemberImage(member) - } - Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { - ChatItemViewShortHand(cItem, itemSeparation, range, false) - } - } - } - } - if (cItem.content.showMemberName) { - DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { - MemberNameAndRole(range) - Item() - } - } else { - Item() - } } - } else { - ChatItemBox { - AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + + @Composable + fun Item() { + ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier, cItem.id, selectedChatItems) + } + Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + val member = cItem.chatDir.groupMember + Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + MemberImage(member) + } + Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { + ChatItemViewShortHand(cItem, itemSeparation, range, false) + } + } } - Row( - Modifier - .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) - .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(swipeableOrSelectionModifier) - ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + } + if (cItem.content.showMemberName) { + DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { + MemberNameAndRole(range) + Item() } + } else { + Item() } } } else { @@ -1497,138 +1531,152 @@ fun BoxScope.ChatItemsList( AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } - Box( + Row( Modifier - .padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp) + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(if (selectionVisible) Modifier else swipeableModifier) + .then(swipeableOrSelectionModifier) ) { ChatItemViewShortHand(cItem, itemSeparation, range) } } } - } else { // direct message + } else { ChatItemBox { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } - Box( - Modifier.padding( - start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp, - end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false), - ) + Modifier + .padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) + .then(if (selectionVisible) Modifier else swipeableModifier) ) { ChatItemViewShortHand(cItem, itemSeparation, range) } } } - if (selectionVisible) { - Box(Modifier.matchParentSize().clickable { - val checked = selectedChatItems.value?.contains(cItem.id) == true - selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) - }) + } else { // direct message + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + + Box( + Modifier.padding( + start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false), + ) + .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) + .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range) + } } } - } - if (itemSeparation.date != null) { - DateSeparator(itemSeparation.date) - } - ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) - } - } - LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), - state = listState.value, - contentPadding = PaddingValues( - top = topPaddingToContent, - bottom = composeViewHeight.value - ), - reverseLayout = true, - additionalBarOffset = composeViewHeight, - additionalTopBar = rememberUpdatedState(chatsCtx.contentTag == null && reportsCount > 0), - chatBottomBar = remember { appPrefs.chatBottomBar.state } - ) { - val mergedItemsValue = mergedItems.value - itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged -> - val isLastItem = index == mergedItemsValue.items.lastIndex - val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null - val listItem = merged.newest() - val item = listItem.item - val range = if (merged is MergedItem.Grouped) { - merged.rangeInReversed.value - } else { - null - } - val showAvatar = shouldShowAvatar(item, listItem.nextItem) - val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } - val itemSeparation: ItemSeparation - val prevItemSeparationLargeGap: Boolean - if (merged is MergedItem.Single || isRevealed.value) { - val prev = listItem.prevItem - itemSeparation = getItemSeparation(item, prev) - val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem - prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) - } else { - itemSeparation = getItemSeparation(item, null) - prevItemSeparationLargeGap = false - } - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { - if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) - } - - if (last != null) { - // no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items - DateSeparator(last.meta.itemTs) - } - if (item.isRcvNew) { - val itemIds = when (merged) { - is MergedItem.Single -> listOf(merged.item.item.id) - is MergedItem.Grouped -> merged.items.map { it.item.id } - } - MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead) - } - } - } - FloatingButtons( - chatsCtx, - reversedChatItems, - chatInfoUpdated, - topPaddingToContent, - topPaddingToContentPx, - loadingMoreItems, - loadingTopItems, - loadingBottomItems, - animatedScrollingInProgress, - mergedItems, - unreadCount, - maxHeight, - composeViewHeight, - searchValue, - markChatRead, - listState, - loadMessages - ) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) - - LaunchedEffect(Unit) { - snapshotFlow { listState.value.isScrollInProgress } - .collect { - chatViewScrollState.value = it - } - } - LaunchedEffect(Unit) { - snapshotFlow { listState.value.isScrollInProgress } - .filter { !it } - .collect { - if (animatedScrollingInProgress.value) { - animatedScrollingInProgress.value = false + if (selectionVisible) { + Box(Modifier.matchParentSize().clickable { + val checked = selectedChatItems.value?.contains(cItem.id) == true + selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) + }) } } + } + if (itemSeparation.date != null) { + DateSeparator(itemSeparation.date) + } + ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) } } + LazyColumnWithScrollBar( + Modifier.align(Alignment.BottomCenter), + state = listState.value, + contentPadding = PaddingValues( + top = topPaddingToContent, + bottom = composeViewHeight.value + ), + reverseLayout = true, + additionalBarOffset = composeViewHeight, + additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && reportsCount > 0), // TODO [knocking] && support chats unread count > 0 ? + chatBottomBar = remember { appPrefs.chatBottomBar.state } + ) { + val mergedItemsValue = mergedItems.value + itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged -> + val isLastItem = index == mergedItemsValue.items.lastIndex + val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null + val listItem = merged.newest() + val item = listItem.item + val range = if (merged is MergedItem.Grouped) { + merged.rangeInReversed.value + } else { + null + } + val showAvatar = shouldShowAvatar(item, listItem.nextItem) + val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } + val itemSeparation: ItemSeparation + val prevItemSeparationLargeGap: Boolean + if (merged is MergedItem.Single || isRevealed.value) { + val prev = listItem.prevItem + itemSeparation = getItemSeparation(item, prev) + val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem + prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) + } else { + itemSeparation = getItemSeparation(item, null) + prevItemSeparationLargeGap = false + } + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } + + if (last != null) { + // no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items + DateSeparator(last.meta.itemTs) + } + if (item.isRcvNew) { + val itemIds = when (merged) { + is MergedItem.Single -> listOf(merged.item.item.id) + is MergedItem.Grouped -> merged.items.map { it.item.id } + } + MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead) + } + } + } + FloatingButtons( + chatsCtx, + reversedChatItems, + chatInfoUpdated, + topPaddingToContent, + topPaddingToContentPx, + loadingMoreItems, + loadingTopItems, + loadingBottomItems, + animatedScrollingInProgress, + mergedItems, + unreadCount, + maxHeight, + composeViewHeight, + searchValue, + markChatRead, + listState, + loadMessages + ) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) + + LaunchedEffect(Unit) { + snapshotFlow { listState.value.isScrollInProgress } + .collect { + chatViewScrollState.value = it + } + } + LaunchedEffect(Unit) { + snapshotFlow { listState.value.isScrollInProgress } + .filter { !it } + .collect { + if (animatedScrollingInProgress.value) { + animatedScrollingInProgress.value = false + } + } + } } private suspend fun loadLastItems(chatsCtx: ChatModel.ChatsContext, chatId: State, listState: State, loadItems: State Boolean>) { @@ -2119,6 +2167,7 @@ private fun FloatingDate( } } +// TODO [knocking] same for member support chats? @Composable private fun SaveReportsStateOnDispose(chatsCtx: ChatModel.ChatsContext, listState: State) { DisposableEffect(Unit) { @@ -2499,6 +2548,7 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List Unit)? = null) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.accept_pending_member_alert_title), + text = generalGetString(MR.strings.accept_pending_member_alert_question), + buttons = { + Column { + // Accept as member + SectionItemView({ + AlertManager.shared.hideAlert() + acceptMember(rhId, groupInfo, member, GroupMemberRole.Member, close) + }) { + Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_member), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Accept as observer + SectionItemView({ + AlertManager.shared.hideAlert() + acceptMember(rhId, groupInfo, member, GroupMemberRole.Observer, close) + }) { + Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_observer), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, role: GroupMemberRole, close: (() -> Unit)?) { + withBGApi { + val acceptedMember = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) + if (acceptedMember != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, acceptedMember) + } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, acceptedMember) + } + } + close?.invoke() + } +} 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 de9fc26905..0f96a551d1 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 @@ -333,7 +333,9 @@ suspend fun MutableState.processPickedMedia(uris: List, text: @Composable fun ComposeView( + rhId: Long?, chatModel: ChatModel, + chatsCtx: ChatModel.ChatsContext, chat: Chat, composeState: MutableState, attachmentOption: MutableState, @@ -466,6 +468,7 @@ fun ComposeView( rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), live = live, ttl = ttl, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) @@ -473,7 +476,7 @@ fun ComposeView( if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatModel.chatsContext.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + chatsCtx.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -498,15 +501,17 @@ fun ComposeView( rh = rhId, toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, + toScope = chat.chatInfo.groupChatScope(), fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, + fromScope = fromChatInfo.groupChatScope(), itemIds = forwardedItem.map { it.id }, ttl = ttl ) withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - chatModel.chatsContext.addChatItem(rhId, chat.chatInfo, chatItem) + chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem) } } @@ -566,9 +571,10 @@ fun ComposeView( suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) if (cItems != null) { + // TODO [knocking] create report chat items in support scope withContext(Dispatchers.Main) { cItems.forEach { chatItem -> - chatModel.chatsContext.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + chatsCtx.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) } } } @@ -581,7 +587,7 @@ fun ComposeView( val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContact(chat.remoteHostId, contact) + chatsCtx.updateContact(chat.remoteHostId, contact) } } } @@ -594,13 +600,14 @@ fun ComposeView( rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), itemId = ei.meta.itemId, updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) if (updatedItem != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + chatsCtx.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) } } return updatedItem?.chatItem @@ -891,7 +898,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatsContext.chatItems.value.findLast { it.meta.editable } + val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } @@ -1002,9 +1009,25 @@ fun ComposeView( val userCanSend = rememberUpdatedState(chat.chatInfo.userCanSend) val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled) val userIsObserver = rememberUpdatedState(chat.userIsObserver) + val userIsPending = rememberUpdatedState(chat.userIsPending) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) Column { + if ( + chat.chatInfo is ChatInfo.Group + && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + && chatsCtx.secondaryContextFilter.groupScopeInfo is GroupChatScopeInfo.MemberSupport + && chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ != null + && chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_.memberPending + && composeState.value.contextItem == ComposeContextItem.NoContextItem + && composeState.value.preview == ComposePreview.NoPreview + ) { + ComposeContextPendingMemberActionsView( + rhId = rhId, + groupInfo = chat.chatInfo.groupInfo, + member = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ + ) + } if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } @@ -1164,7 +1187,8 @@ fun ComposeView( needToAllowVoiceToContact, allowedVoiceByPrefs, allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, + userIsObserver = if (chatsCtx.secondaryContextFilter == null) userIsObserver.value else false, + userIsPending = if (chatsCtx.secondaryContextFilter == null) userIsPending.value else false, userCanSend = userCanSend.value, sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index b9538bc691..c934b6d5d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -77,7 +77,7 @@ fun SelectedItemsButtonsToolbar( val forwardCountProhibited = remember { mutableStateOf(false) } Box { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty - ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) Row( Modifier .matchParentSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 5524eff655..f2e636a01a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -15,9 +15,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.* import chat.simplex.common.model.* @@ -46,6 +44,7 @@ fun SendMsgView( needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, userIsObserver: Boolean, + userIsPending: Boolean, userCanSend: Boolean, sendButtonColor: Color = MaterialTheme.colors.primary, allowVoiceToContact: () -> Unit, @@ -90,6 +89,7 @@ fun SendMsgView( textStyle, showDeleteTextButton, userIsObserver, + userIsPending, if (clicksOnTextFieldDisabled) "" else placeholder, showVoiceButton, onMessageChange, @@ -581,6 +581,7 @@ fun PreviewSendMsgView() { needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, @@ -617,6 +618,7 @@ fun PreviewSendMsgViewEditing() { needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, @@ -653,6 +655,7 @@ fun PreviewSendMsgViewInProgress() { needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, 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 10694d13bf..369a498c25 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 @@ -144,7 +144,7 @@ fun AddGroupMembersLayout( horizontalArrangement = Arrangement.Center ) { ChatInfoToolbarTitle( - ChatInfo.Group(groupInfo), + ChatInfo.Group(groupInfo, groupChatScope = null), imageSize = 60.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight ) 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 22956738e7..aeb4041008 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 @@ -74,6 +74,11 @@ fun ModalData.GroupChatInfoView( val chatItemTTL = remember(groupInfo.id) { mutableStateOf(if (groupInfo.chatItemTTL != null) ChatItemTTL.fromSeconds(groupInfo.chatItemTTL) else null) } val deletingItems = rememberSaveable(groupInfo.id) { mutableStateOf(false) } val scope = rememberCoroutineScope() + val activeSortedMembers = remember { chatModel.groupMembers }.value + .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } + .sortedByDescending { it.memberRole } + + Log.e(TAG, "######### GroupChatInfoView chatModel.groupMembers length = ${chatModel.groupMembers.value.count()}") GroupChatInfoLayout( chat, @@ -95,9 +100,7 @@ fun ModalData.GroupChatInfoView( setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, - activeSortedMembers = remember { chatModel.groupMembers }.value - .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } - .sortedByDescending { it.memberRole }, + activeSortedMembers = activeSortedMembers, developerTools, onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, groupLink, @@ -140,6 +143,15 @@ fun ModalData.GroupChatInfoView( addOrEditWelcomeMessage = { ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, rhId, groupInfo, close) } }, + openMemberSupport = { + ModalManager.end.showCustomModal { close -> + MemberSupportView( + chat, + groupInfo, + close + ) + } + }, openPreferences = { ModalManager.end.showCustomModal { close -> GroupPreferencesView( @@ -317,6 +329,30 @@ fun AddGroupMembersButton( ) } +@Composable +fun UserSupportChatButton(groupInfo: GroupInfo) { + val scope = rememberCoroutineScope() + val scrollToItemId: MutableState = remember { mutableStateOf(null) } + + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.button_support_chat), + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + iconColor = MaterialTheme.colors.secondary, + ) +} + @Composable fun ModalData.GroupChatInfoLayout( chat: Chat, @@ -337,6 +373,7 @@ fun ModalData.GroupChatInfoLayout( showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, addOrEditWelcomeMessage: () -> Unit, + openMemberSupport: () -> Unit, openPreferences: () -> Unit, deleteGroup: () -> Unit, clearChat: () -> Unit, @@ -422,6 +459,29 @@ fun ModalData.GroupChatInfoLayout( SectionSpacer() + var anyTopSectionRowShow = false + SectionView { + if (groupInfo.membership.supportChat != null) { + anyTopSectionRowShow = true + UserSupportChatButton(groupInfo) + } + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + } + if (anyTopSectionRowShow) { + SectionDividerSpaced(maxBottomPadding = false) + } + SectionView { if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { EditGroupProfileButton(editGroupProfile) @@ -431,19 +491,17 @@ fun ModalData.GroupChatInfoLayout( } val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences GroupPreferencesButton(prefsTitleId, openPreferences) - if (groupInfo.canModerate) { - GroupReportsButton { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) - } - } - } + } + val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + SectionTextFooter(stringResource(footerId)) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + SectionView { if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) } else { SendReceiptsOptionDisabled() } - WallpaperButton { ModalManager.end.showModal { val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } @@ -453,12 +511,9 @@ fun ModalData.GroupChatInfoLayout( } } } + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs - SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - - ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { @@ -481,7 +536,7 @@ fun ModalData.GroupChatInfoLayout( } if (activeSortedMembers.size > 8) { SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { - SearchRowView(searchText) + MemberListSearchRowView(searchText) } } SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { @@ -631,17 +686,14 @@ private fun SelectedItemsCounterToolbarSetter( } @Composable -fun ChatTTLSection(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State) { +fun ChatTTLOption(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State) { Box { - SectionView { - TtlOptions( - chatItemTTL, - enabled = remember { derivedStateOf { !deletingItems.value } }, - onSelected = setChatItemTTL, - default = chatModel.chatItemTTL - ) - SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) - } + TtlOptions( + chatItemTTL, + enabled = remember { derivedStateOf { !deletingItems.value } }, + onSelected = setChatItemTTL, + default = chatModel.chatItemTTL + ) if (deletingItems.value) { Box(Modifier.matchParentSize()) { ProgressIndicator() @@ -683,6 +735,15 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { } } +@Composable +private fun MemberSupportButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_flag), // TODO [knocking] change icon + stringResource(MR.strings.member_support), + click = onClick + ) +} + @Composable private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) { SettingsActionItem( @@ -820,7 +881,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr } @Composable -private fun MemberVerifiedShield() { +fun MemberVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary) } @@ -941,7 +1002,7 @@ private fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { } @Composable -private fun SearchRowView( +fun MemberListSearchRowView( searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } ) { Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) { @@ -1016,7 +1077,18 @@ fun PreviewGroupChatInfoLayout() { selectedItems = remember { mutableStateOf(null) }, appBar = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) }, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) } + addMembers = {}, + showMemberInfo = {}, + editGroupProfile = {}, + addOrEditWelcomeMessage = {}, + openMemberSupport = {}, + openPreferences = {}, + deleteGroup = {}, + clearChat = {}, + leaveGroup = {}, + manageGroupLink = {}, + onSearchClicked = {}, + deletingItems = remember { mutableStateOf(true) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 285c96165c..a6c21574ef 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 @@ -299,6 +299,30 @@ fun GroupMemberInfoLayout( } } + @Composable + fun SupportChatButton() { + val scope = rememberCoroutineScope() + val scrollToItemId: MutableState = remember { mutableStateOf(null) } // TODO [knocking] scroll to report from support chat? + + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.button_support_chat), + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + iconColor = MaterialTheme.colors.secondary, + ) + } + @Composable fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) @@ -413,6 +437,12 @@ fun GroupMemberInfoLayout( if (member.memberActive) { SectionView { + // TODO [knocking] allow creating support chat with members that don't have it (currently doesn't work) + // groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + // (member.memberRole <= GroupMemberRole.Moderator || member.supportChat != null) + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator && member.supportChat != null) { + SupportChatButton() + } if (connectionCode != null) { VerifyCodeButton(member.verified, verifyClicked) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt index 2c4d4b16a8..62f1a4337c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -44,7 +44,7 @@ fun SelectedItemsMembersToolbar( ) { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty Box(Modifier.alpha(0f)) { - ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) } Row( Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt index 1a63375432..8f57d01619 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -29,6 +29,7 @@ private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE @Composable fun GroupMentions( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, composeState: MutableState, composeViewFocusRequester: FocusRequester?, @@ -42,12 +43,31 @@ fun GroupMentions( val mentionName = remember { mutableStateOf("") } val mentionRange = remember { mutableStateOf(null) } val mentionMemberId = remember { mutableStateOf(null) } + + fun contextMemberFilter(member: GroupMember): Boolean = + when (chatsCtx.secondaryContextFilter) { + null -> true + is SecondaryContextFilter.GroupChatScopeContext -> + when (chatsCtx.secondaryContextFilter.groupScopeInfo) { + is GroupChatScopeInfo.MemberSupport -> { + val scopeMember = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ + if (scopeMember != null) { + member.memberRole >= GroupMemberRole.Moderator || member.groupMemberId == scopeMember.groupMemberId + } else { + member.memberRole >= GroupMemberRole.Moderator + } + } + } + is SecondaryContextFilter.MsgContentTagContext -> false + } + val filteredMembers = remember { derivedStateOf { val members = chatModel.groupMembers.value .filter { val status = it.memberStatus status != GroupMemberStatus.MemLeft && status != GroupMemberStatus.MemRemoved && status != GroupMemberStatus.MemInvited + && contextMemberFilter(it) } .sortedByDescending { it.memberRole } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 12c5b65769..b8db5969a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -17,7 +17,9 @@ import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* private val featureRoles: List> = listOf( @@ -71,6 +73,16 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> preferences = currentPreferences }, savePrefs = ::savePrefs, + openMemberAdmission = { + ModalManager.end.showCustomModal { close -> + MemberAdmissionView( + chatModel, + rhId, + chatId, + close + ) + } + } ) } } @@ -83,10 +95,15 @@ private fun GroupPreferencesLayout( applyPrefs: (FullGroupPreferences) -> Unit, reset: () -> Unit, savePrefs: () -> Unit, + openMemberAdmission: () -> Unit, ) { ColumnWithScrollBar { val titleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences AppBarTitle(stringResource(titleId)) + if (groupInfo.businessChat == null) { + MemberAdmissionButton(openMemberAdmission) + SectionDividerSpaced(maxBottomPadding = false) + } val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) } val onTTLUpdated = { ttl: Int? -> applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl))) @@ -156,6 +173,15 @@ private fun GroupPreferencesLayout( } } +@Composable +private fun MemberAdmissionButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.member_admission), + click = onClick + ) +} + @Composable private fun FeatureSection( feature: GroupFeature, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index 1eeeb99c93..bb05ede81b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -53,7 +53,7 @@ fun GroupReportsAppBar( } @Composable -private fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { +fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { LaunchedEffect(Unit) { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() @@ -69,7 +69,7 @@ private fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { } suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { - val reportsChatsCtx = ChatModel.ChatsContext(contentTag = MsgContentTag.Report) + val reportsChatsCtx = ChatModel.ChatsContext(secondaryContextFilter = SecondaryContextFilter.MsgContentTagContext(MsgContentTag.Report)) openChat(secondaryChatsCtx = reportsChatsCtx, chatModel.remoteHostId(), chatInfo) ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> ModalView({}, showAppBar = false) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt new file mode 100644 index 0000000000..48171bfeb7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -0,0 +1,152 @@ +package chat.simplex.common.views.chat.group + +import InfoRow +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit) { + val groupInfo = remember { derivedStateOf { + val ch = m.getChat(chatId) + val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo + if (g == null || ch.remoteHostId != rhId) null else g + }} + val gInfo = groupInfo.value ?: return + var admission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.groupProfile.memberAdmission) } + var currentAdmission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(admission) } + + fun saveAdmission(afterSave: () -> Unit = {}) { + withBGApi { + val gp = gInfo.groupProfile.copy(memberAdmission = admission) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) + if (g != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, g) + currentAdmission = admission + } + } + afterSave() + } + } + ModalView( + close = { + if (admission == currentAdmission) close() + else showUnsavedChangesAlert({ saveAdmission(close) }, close) + }, + ) { + MemberAdmissionLayout( + admission, + currentAdmission, + gInfo, + applyAdmission = { admsn -> + admission = admsn + }, + reset = { + admission = currentAdmission + }, + saveAdmission = ::saveAdmission, + ) + } +} + +@Composable +private fun MemberAdmissionLayout( + admission: GroupMemberAdmission?, + currentAdmission: GroupMemberAdmission?, + groupInfo: GroupInfo, + applyAdmission: (GroupMemberAdmission) -> Unit, + reset: () -> Unit, + saveAdmission: () -> Unit, +) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.member_admission)) + val review = remember(admission) { mutableStateOf(admission?.review) } + AdmissionSection(MR.strings.admission_stage_review, MR.strings.admission_stage_review_descr, review, groupInfo) { criteria -> + if (admission != null) { + applyAdmission(admission.copy(review = criteria)) + } else { + applyAdmission(GroupMemberAdmission(review = criteria)) + } + } + if (groupInfo.isOwner) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + ResetSaveButtons( + reset = reset, + save = saveAdmission, + disabled = admission == currentAdmission + ) + } + SectionBottomSpacer() + } +} + +private val memberCriterias: List> = listOf( + null to generalGetString(MR.strings.member_criteria_off), + MemberCriteria.All to generalGetString(MR.strings.member_criteria_all) +) + +@Composable +private fun AdmissionSection( + admissionStageStrId: StringResource, + admissionStageDescrStrId: StringResource, + memberCriteria: State, + groupInfo: GroupInfo, + onSelected: (MemberCriteria?) -> Unit +) { + SectionView { + if (groupInfo.isOwner) { + ExposedDropDownSettingRow( + generalGetString(admissionStageStrId), + memberCriterias, + memberCriteria, + onSelected = { value -> + onSelected(value) + } + ) + } else { + InfoRow( + stringResource(admissionStageStrId), + memberCriteria.value?.text ?: generalGetString(MR.strings.member_criteria_off) + ) + } + } + SectionTextFooter(stringResource( admissionStageDescrStrId)) +} + +@Composable +private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { + SectionView { + SectionItemView(reset, disabled = disabled) { + Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + } + SectionItemView(save, disabled = disabled) { + Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + } + } +} + +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.save_admission_question), + confirmText = generalGetString(MR.strings.save_and_notify_group_members), + dismissText = generalGetString(MR.strings.exit_without_saving), + onConfirm = save, + onDismiss = revert, + ) +} 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 new file mode 100644 index 0000000000..86bb9b7255 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -0,0 +1,122 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* +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 +private fun MemberSupportChatView(memberSupportChatsCtx: ChatModel.ChatsContext, staleChatId: State, scrollToItemId: MutableState) { + ChatView(memberSupportChatsCtx, staleChatId, scrollToItemId, onComposed = {}) +} + +@Composable +fun MemberSupportChatAppBar( + chatsCtx: ChatModel.ChatsContext, + scopeMember_: GroupMember?, + close: () -> Unit, + onSearchValueChanged: (String) -> Unit +) { + val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + val showSearch = rememberSaveable { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch.value) { + close() + } else { + onSearchValueChanged("") + showSearch.value = false + } + } + BackHandler(onBack = onBackClicked) + if (scopeMember_ != null) { + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + title = { MemberSupportChatToolbarTitle(scopeMember_) }, + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + } else { + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + fixedTitleText = stringResource(MR.strings.support_chat), + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + } + ItemsReload(chatsCtx) +} + +@Composable +fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + MemberProfileImage(size = imageSize * fontSizeSqrtMultiplier, member, iconColor) + Column( + Modifier.padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.displayName, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + if (member.fullName != "" && member.fullName != member.displayName && member.localAlias.isEmpty()) { + Text( + member.fullName, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +suspend fun showMemberSupportChatView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo, scopeInfo: GroupChatScopeInfo) { + val memberSupportChatsCtx = ChatModel.ChatsContext(secondaryContextFilter = SecondaryContextFilter.GroupChatScopeContext(scopeInfo)) + openChat(secondaryChatsCtx = memberSupportChatsCtx, chatModel.remoteHostId(), chatInfo) + ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> + ModalView({}, showAppBar = false) { + if (chatInfo is ChatInfo.Group && chatInfo.groupChatScope != null) { + MemberSupportChatView(memberSupportChatsCtx, staleChatId, scrollToItemId) + } else { + LaunchedEffect(Unit) { + close() + } + } + } + } +} 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 new file mode 100644 index 0000000000..55c2126771 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -0,0 +1,260 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewLongClickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +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 +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.unreadCountStr +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.launch + +@Composable +fun ModalData.MemberSupportView( + chat: Chat, + groupInfo: GroupInfo, + close: () -> Unit +) { + KeyChangeEffect(chat.id) { + close() + } + ModalView(close = close) { + MemberSupportViewLayout( + chat, + groupInfo + ) + } +} + +@Composable +private fun ModalData.MemberSupportViewLayout( + chat: Chat, + groupInfo: GroupInfo +) { + val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + val scope = rememberCoroutineScope() + val scrollToItemId: MutableState = remember { mutableStateOf(null) } // TODO [knocking] scroll to report from support chat? + + val membersWithChats = remember { chatModel.groupMembers }.value + .filter { it.supportChat != null && it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } + .sortedWith( + compareByDescending { it.memberPending } + .thenByDescending { (it.supportChat?.mentions ?: 0) > 0 } + .thenByDescending { (it.supportChat?.memberAttention ?: 0) > 0 } + .thenByDescending { (it.supportChat?.unread ?: 0) > 0 } + .thenByDescending { it.supportChat?.chatTs } + ) + + val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } } + val filteredmembersWithChats = remember(membersWithChats) { + derivedStateOf { + val s = searchText.value.text.trim().lowercase() + if (s.isEmpty()) membersWithChats else membersWithChats.filter { m -> m.anyNameContains(s) } + } + } + + LazyColumnWithScrollBar( + contentPadding = + PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false) + ) + ) { + item { + AppBarTitle(stringResource(MR.strings.member_support)) + } + + if (membersWithChats.isEmpty()) { + item { + Box(Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { + Text(generalGetString(MR.strings.no_support_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + } else { + item { + SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { + MemberListSearchRowView(searchText) + } + } + items(filteredmembersWithChats.value, key = { it.groupMemberId }) { member -> + Divider() + val showMenu = remember { mutableStateOf(false) } + SectionItemViewLongClickable( + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + Box(contentAlignment = Alignment.CenterStart) { + DropDownMenuForSupportChat(chat.remoteHostId, member, groupInfo, showMenu) + SupportChatRow(member) + } + } + } + item { + Divider() + SectionBottomSpacer() + } + } + } +} + +@Composable +fun SupportChatRow(member: GroupMember) { + fun memberStatus(): String { + return if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else if (member.memberPending) { + member.memberStatus.text + } else { + member.memberRole.text + } + } + + @Composable + fun SupportChatUnreadIndicator(supportChat: GroupSupportChat) { + Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { + if (supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0) { + val indicatorTint = when { + supportChat.mentions > 0 || supportChat.memberAttention > 0 -> MaterialTheme.colors.primaryVariant + else -> MaterialTheme.colors.secondary + } + if (supportChat.mentions == 1 && supportChat.unread == 1) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(indicatorTint, shape = CircleShape), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = Color.White, + modifier = Modifier.size(9.sp.toDp()) + ) + } + } else { + if (supportChat.mentions > 0 && supportChat.unread > 1) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = indicatorTint, + modifier = Modifier.size(12.sp.toDp()).offset(y = 3.sp.toDp()) + ) + } + + Text( + unreadCountStr(supportChat.unread), + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = 3.sp.toDp()) + .background(indicatorTint, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) + } + } + } + } + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } + + Text( + memberStatus(), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row { + if (member.memberPending) { + Icon( + painterResource(MR.images.ic_flag_filled), + contentDescription = null, + Modifier.padding(end = 3.dp).size(16.dp), + tint = MaterialTheme.colors.primaryVariant + ) + } + if (member.supportChat != null) { + SupportChatUnreadIndicator(member.supportChat) + } + } + } +} + +@Composable +private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { + DefaultDropdownMenu(showMenu) { + if (member.memberPending) { + ItemAction(stringResource(MR.strings.accept_pending_member_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + acceptMemberDialog(rhId, groupInfo, member) + showMenu.value = false + }) + } + ItemAction(stringResource(MR.strings.remove_pending_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + removeMemberDialog(rhId, groupInfo, member, chatModel) + showMenu.value = false + }) + // TODO [knocking] mark read, mark unread + // ItemAction(stringResource(MR.strings.mark_unread), painterResource(MR.images.ic_mark_chat_unread), onClick = { + // showMenu.value = false + // }) + } +} 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 5bbbc33741..3ea75a74a7 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 @@ -271,6 +271,7 @@ fun ChatItemView( } } + // improvement could be to track "forwarded from" scope and open it @Composable fun GoToItemButton(alignStart: Boolean, parentActivated: State) { val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId @@ -1422,7 +1423,7 @@ fun PreviewChatItemView( chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello") ) { ChatItemView( - chatsCtx = ChatModel.ChatsContext(contentTag = null), + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, ChatInfo.Direct.sampleData, chatItem, @@ -1472,7 +1473,7 @@ fun PreviewChatItemView( fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( - chatsCtx = ChatModel.ChatsContext(contentTag = null), + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), 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 958b794bd7..b6b7a1aa64 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 @@ -615,7 +615,8 @@ fun markChatRead(c: Chat) { chatModel.controller.apiChatRead( chat.remoteHostId, chat.chatInfo.chatType, - chat.chatInfo.apiId + chat.chatInfo.apiId, + chat.chatInfo.groupChatScope() ) chat = chatModel.getChat(chat.id) ?: return@withApi } @@ -651,7 +652,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withContext(Dispatchers.Main) { chatModel.chatsContext.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) - chatModel.chatsContext.updateChatTagReadNoContentTag(chat, wasUnread) + chatModel.chatsContext.updateChatTagReadInPrimaryContext(chat, wasUnread) } } } @@ -886,7 +887,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch ChatInfo.Direct(contact.copy(chatSettings = chatSettings)) } is ChatInfo.Group -> with(chatInfo) { - ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings)) + ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings), groupChatScope = null) } else -> null } @@ -914,7 +915,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch val updatedChat = chatModel.getChat(chatInfo.id) if (updatedChat != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.updateChatTagReadNoContentTag(updatedChat, wasUnread) + chatModel.chatsContext.updateChatTagReadInPrimaryContext(updatedChat, wasUnread) } } val current = currentState?.value 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 90a176658d..53ae1abb50 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -158,6 +158,7 @@ Error loading details Error adding member(s) Error joining group + Error accepting member Cannot receive file Sender cancelled file transfer. Unknown servers! @@ -487,6 +488,7 @@ The image cannot be decoded. Please, try a different image or contact developers. The video cannot be decoded. Please, try a different video or contact developers. you are observer + reviewed by moderators You can\'t send messages! Please contact group admin. Files and media prohibited! @@ -1048,6 +1050,7 @@ Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Edit image Delete image + Save admission settings? Save preferences? Save and notify contact Save and notify contacts @@ -1567,6 +1570,8 @@ invited %1$s connected + accepted %1$s + accepted you left changed role of %s to %s blocked %s @@ -1644,6 +1649,8 @@ invited pending approval pending + pending review + review connecting (introduced) connecting (introduction invitation) connecting (accepted) @@ -1750,7 +1757,7 @@ Remove member? Remove members? Remove member - + Support chat Send direct message Member will be removed from group - this cannot be undone! Members will be removed from group - this cannot be undone! @@ -2144,6 +2151,26 @@ owners Enabled for + + Member admission + Review + Review new members before admitting to group. + off + all + + + Member support + No support chats + + + Support + Remove + Accept + Accept member + Member will join the group, accept member? + Accept as member + Accept as observer + What\'s new New in %s diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index d0d4fb5e92..c6b9a2f73e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -48,6 +48,7 @@ actual fun PlatformTextField( textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, + userIsPending: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, @@ -204,6 +205,8 @@ actual fun PlatformTextField( showDeleteTextButton.value = cs.message.text.split("\n").size >= 4 && !cs.inProgress if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) + } else if (userIsPending) { + ComposeOverlay(MR.strings.reviewed_by_moderators, textStyle, padding) } else if (userIsObserver) { ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9d064ea691..d0e6803015 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1578,7 +1578,7 @@ processChatCommand' vr = \case case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force - (g', m', scopeInfo) <- liftIO $ mkGroupChatScope g m + (g', m', scopeInfo) <- mkGroupChatScope g m createInternalChatItem user (CDGroupSnd g' scopeInfo) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m') Nothing pure $ CRGroupMemberRatchetSyncStarted user g' m' cStats _ -> throwChatError CEGroupMemberNotActive @@ -2244,7 +2244,7 @@ processChatCommand' vr = \case pure m {memberStatus = GSMemRemoved} deletePendingMember :: ([ChatError], [GroupMember], [AChatItem]) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem]) deletePendingMember (accErrs, accDeleted, accACIs) user gInfo recipients m = do - (m', scopeInfo) <- liftIO $ mkMemberSupportChatInfo m + (m', scopeInfo) <- mkMemberSupportChatInfo m (errs, deleted, acis) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs) deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) @@ -2280,7 +2280,7 @@ processChatCommand' vr = \case cancelFilesInProgress user filesInfo let recipients = filter memberCurrentOrPending members msg <- sendGroupMessage' user gInfo recipients XGrpLeave - (gInfo', scopeInfo) <- liftIO $ mkLocalGroupChatScope gInfo + (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] -- TODO delete direct connections that were unused diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 897c36fd6a..13e58eb36d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1323,7 +1323,7 @@ getGroupRecipients vr user gInfo@GroupInfo {membership} scope modsCompatVersion compatible GroupMember {activeConn, memberChatVRange} = maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= modsCompatVersion -mkLocalGroupChatScope :: GroupInfo -> IO (GroupInfo, Maybe GroupChatScopeInfo) +mkLocalGroupChatScope :: GroupInfo -> CM (GroupInfo, Maybe GroupChatScopeInfo) mkLocalGroupChatScope gInfo@GroupInfo {membership} | memberPending membership = do (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo @@ -1331,7 +1331,7 @@ mkLocalGroupChatScope gInfo@GroupInfo {membership} | otherwise = pure (gInfo, Nothing) -mkGroupChatScope :: GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGroupChatScope :: GroupInfo -> GroupMember -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) mkGroupChatScope gInfo@GroupInfo {membership} m | memberPending membership = do (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo @@ -1344,38 +1344,40 @@ mkGroupChatScope gInfo@GroupInfo {membership} m mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = - liftIO (mkGroupChatScope gInfo m) >>= \case + mkGroupChatScope gInfo m >>= \case groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope (_, _, Nothing) -> case msgScope_ of Nothing -> pure (gInfo, m, Nothing) Just (MSMember mId) | sameMemberId mId membership -> do - (gInfo', scopeInfo) <- liftIO $ mkGroupSupportChatInfo gInfo + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo pure (gInfo', m, Just scopeInfo) | otherwise -> do referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId -- TODO [knocking] return patched _referredMember' too? - (_referredMember', scopeInfo) <- liftIO $ mkMemberSupportChatInfo referredMember + (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember pure (gInfo, m, Just scopeInfo) -mkGroupSupportChatInfo :: GroupInfo -> IO (GroupInfo, GroupChatScopeInfo) +mkGroupSupportChatInfo :: GroupInfo -> CM (GroupInfo, GroupChatScopeInfo) mkGroupSupportChatInfo gInfo@GroupInfo {membership} = case supportChat membership of Nothing -> do - chatTs <- getCurrentTime - let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 1 0 0}} + chatTs <- liftIO getCurrentTime + withStore' $ \db -> setSupportChatTs db (groupMemberId' membership) chatTs + let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 0 0 0}} scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} pure (gInfo', scopeInfo) Just _supportChat -> let scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} in pure (gInfo, scopeInfo) -mkMemberSupportChatInfo :: GroupMember -> IO (GroupMember, GroupChatScopeInfo) -mkMemberSupportChatInfo m@GroupMember {supportChat} = +mkMemberSupportChatInfo :: GroupMember -> CM (GroupMember, GroupChatScopeInfo) +mkMemberSupportChatInfo m@GroupMember {groupMemberId, supportChat} = case supportChat of Nothing -> do - chatTs <- getCurrentTime - let m' = m {supportChat = Just $ GroupSupportChat chatTs 1 0 0} + chatTs <- liftIO getCurrentTime + withStore' $ \db -> setSupportChatTs db groupMemberId chatTs + let m' = m {supportChat = Just $ GroupSupportChat chatTs 0 0 0} scopeInfo = GCSIMemberSupport {groupMember_ = Just m'} pure (m', scopeInfo) Just _supportChat -> diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index bdd481d48b..41d7bdad72 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -777,7 +777,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure $ memberStatus membership pure (GSMemConnected, membershipStatus) toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = membershipStatus}} m {memberStatus = mStatus} - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m let cd = CDGroupRcv gInfo' scopeInfo m' createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CIRcvGroupFeature gInfo' @@ -788,7 +788,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if not (memberPending m) then withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected else pure $ memberStatus m - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m memberConnectedChatItem gInfo' scopeInfo m' toView $ CRJoinedGroupMember user gInfo' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn @@ -843,7 +843,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = tags <- newTVarIO [] withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do -- possible improvement is to choose scope based on event (some events specify scope) - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> @@ -936,12 +936,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when continued $ sendPendingGroupMessages user m conn SWITCH qd phase cStats -> do toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m') Nothing QDSnd -> createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvConnEvent $ RCESwitchQueue phase) Nothing RSYNC rss cryptoErr_ cStats -> do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m case (rss, connectionCode, cryptoErr_) of (RSRequired, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr (RSAllowed, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr @@ -1234,7 +1234,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo mem <- acceptGroupJoinRequestAsync user gInfo cReq acceptance useRole profileMode - (gInfo', mem', scopeInfo) <- liftIO $ mkGroupChatScope gInfo mem + (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo' mem' Left rjctReason @@ -1379,7 +1379,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> CM () notifyMemberConnected gInfo m ct_ = do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m memberConnectedChatItem gInfo' scopeInfo m' lift $ mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CRConnectedToGroupMember user gInfo' m' ct_ @@ -2165,7 +2165,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = BCCustomer -> customerId == memberId createProfileUpdatedItem m' = when createItems $ do - (gInfo', m'', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m' + (gInfo', m'', scopeInfo) <- mkGroupChatScope gInfo m' let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p' createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') ciContent itemTs_ @@ -2458,7 +2458,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just (MSMember _) -> GSMemPendingReview _ -> GSMemAnnounced memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do - (announcedMember', scopeInfo) <- liftIO $ getMemNewChatScope announcedMember + (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent event) groupMsgToView gInfo scopeInfo ci @@ -2555,7 +2555,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole - (gInfo'', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo' m + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo'' scopeInfo ci toView CRMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} @@ -2584,7 +2584,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = bm' <- setMemberBlocked bm toggleNtf user bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent groupMsgToView gInfo' scopeInfo ci toView CRMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm, blocked} @@ -2669,7 +2669,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo' scopeInfo ci deleteMessages :: MsgDirectionI d => GroupMember -> SMsgDirection d -> CM () @@ -2682,7 +2682,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberConnection user m -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView gInfo' scopeInfo ci toView $ CRLeftMember user gInfo' m' {memberStatus = GSMemLeft} @@ -2696,7 +2696,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure members -- member records are not deleted to keep history deleteMembersConnections user ms - (gInfo'', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView gInfo'' scopeInfo ci toView $ CRGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' @@ -2707,7 +2707,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = case businessChat of Nothing -> unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' - (g'', m', scopeInfo) <- liftIO $ mkGroupChatScope g' m + (g'', m', scopeInfo) <- mkGroupChatScope g' m toView $ CRGroupUpdated user g g'' (Just m') let cd = CDGroupRcv g'' scopeInfo m' unless (sameGroupProfileInfo p p') $ do @@ -2726,7 +2726,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (groupPreferences p == Just ps') $ do g' <- withStore' $ \db -> updateGroupPreferences db user g ps' toView $ CRGroupUpdated user g g' (Just m) - (g'', m', scopeInfo) <- liftIO $ mkGroupChatScope g' m + (g'', m', scopeInfo) <- mkGroupChatScope g' m let cd = CDGroupRcv g'' scopeInfo m' createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' @@ -2768,7 +2768,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do - (g', m'', scopeInfo) <- liftIO $ mkGroupChatScope g m' + (g', m'', scopeInfo) <- mkGroupChatScope g m' createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CRNewMemberContactReceivedInv user mCt' g' m'' forM_ mContent_ $ \mc -> do @@ -2827,7 +2827,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 5db180aec9..24f598a7e6 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -321,7 +321,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do -- | creates completely new group with a single member - the current user createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile @@ -329,8 +329,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -390,7 +390,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation_ = do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences ExceptT $ withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do @@ -398,8 +398,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -558,13 +558,13 @@ createGroupViaLink' (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where insertGroup_ currentTs = ExceptT $ do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 54d40794b0..d1daace916 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -37,6 +37,7 @@ module Simplex.Chat.Store.Messages deleteOldMessages, MemberAttention (..), updateChatTsStats, + setSupportChatTs, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -419,6 +420,10 @@ updateChatTsStats db User {userId} chatDirection chatTs chatStats_ = case toChat (chatTs, userId, noteFolderId) _ -> pure () +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) + createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt @@ -750,7 +755,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -1812,14 +1817,22 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do :. (userId, noteFolderId, ciCreatedAt afterCI, cChatItemId afterCI) ) -toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) +toChatItemRef :: + (ChatItemId, Maybe ContactId, Maybe GroupId, Maybe GroupChatScopeTag, Maybe GroupMemberId, Maybe NoteFolderId) -> + Either StoreError (ChatRef, ChatItemId) toChatItemRef = \case - (itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId Nothing, itemId) - -- For "chat item refs" we don't care about scope, as they are only used for getting item by id (via getAChatItem). - -- To get the scope without joining group_members, we could additionally store group scope "tag" to differentiate member support chats. - (itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId Nothing, itemId) - (itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId Nothing, itemId) - (itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing + (itemId, Just contactId, Nothing, Nothing, Nothing, Nothing) -> + Right (ChatRef CTDirect contactId Nothing, itemId) + (itemId, Nothing, Just groupId, Nothing, Nothing, Nothing) -> + Right (ChatRef CTGroup groupId Nothing, itemId) + (itemId, Nothing, Just groupId, Just GCSTMemberSupport_, Nothing, Nothing) -> + Right (ChatRef CTGroup groupId (Just (GCSMemberSupport Nothing)), itemId) + (itemId, Nothing, Just groupId, Just GCSTMemberSupport_, Just scopeGMId, Nothing) -> + Right (ChatRef CTGroup groupId (Just (GCSMemberSupport $ Just scopeGMId)), itemId) + (itemId, Nothing, Nothing, Nothing, Nothing, Just folderId) -> + Right (ChatRef CTLocal folderId Nothing, itemId) + (itemId, _, _, _, _, _) -> + Left $ SEBadChatItem itemId Nothing updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> IO () updateDirectChatItemsRead db User {userId} contactId = do @@ -2170,7 +2183,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC @@ -2181,7 +2194,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) @@ -2194,7 +2207,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) @@ -2206,7 +2219,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE chat_item_id = ? |] @@ -2952,7 +2965,7 @@ getChatItemByFileId db vr user@User {userId} fileId = do DB.query db [sql| - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + 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 JOIN files f ON f.chat_item_id = i.chat_item_id WHERE f.user_id = ? AND f.file_id = ? @@ -2974,7 +2987,7 @@ getChatItemByGroupId db vr user@User {userId} groupId = do DB.query db [sql| - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + 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 JOIN groups g ON g.chat_item_id = i.chat_item_id WHERE g.user_id = ? AND g.group_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 0a11f668ff..95795c6aa7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -462,7 +462,7 @@ SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) @@ -474,7 +474,7 @@ SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC @@ -578,7 +578,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) LEFT-JOIN Query: - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + 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 JOIN files f ON f.chat_item_id = i.chat_item_id WHERE f.user_id = ? AND f.file_id = ? @@ -1014,7 +1014,7 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE chat_item_id = ? @@ -1022,7 +1022,7 @@ Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) @@ -1179,7 +1179,7 @@ SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + 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 JOIN groups g ON g.chat_item_id = i.chat_item_id WHERE g.user_id = ? AND g.group_id = ? @@ -1952,7 +1952,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -1995,7 +1995,7 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2004,7 +2004,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2041,7 +2041,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2050,7 +2050,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2086,7 +2086,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2140,7 +2140,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2176,7 +2176,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2185,7 +2185,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2221,7 +2221,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2275,7 +2275,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2311,7 +2311,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2320,7 +2320,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2353,7 +2353,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2404,7 +2404,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2437,7 +2437,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -5460,7 +5460,7 @@ Plan: Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?) +Query: INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO group_profiles (display_name, full_name, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 49660b99ba..60bd409067 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1858,7 +1858,7 @@ $(JQ.deriveJSON defaultJSON ''LocalProfile) $(JQ.deriveJSON defaultJSON ''UserContactRequest) -$(JQ.deriveJSON (enumJSON $ dropPrefix "MC") ''MemberCriteria) +$(JQ.deriveJSON (enumJSON $ dropPrefix "MC") {J.tagSingleConstructors = True} ''MemberCriteria) $(JQ.deriveJSON defaultJSON ''GroupMemberAdmission) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 00d25b533f..9bee2da58c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6955,6 +6955,11 @@ testScopedSupportManyModerators = alice <# "#team (support: bob) dan> 5" bob <# "#team (support) dan> 5" + alice @@@ [("#team","2"), ("@dan","sent invitation to join group team as moderator"), ("@cath","sent invitation to join group team as member"), ("@bob","sent invitation to join group team as member")] + bob @@@ [("#team","2"), ("@alice","received invitation to join group team as member")] + dan @@@ [("#team","2"), ("@alice","received invitation to join group team as moderator")] + cath @@@ [("#team","2"), ("@alice","received invitation to join group team as member")] + alice #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (1, "1"), (0, "2")]) alice #$> ("/_get chat #1(_support:2) count=100", chat, [(1, "3"), (0, "4"), (0, "5")]) bob #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (1, "2")]) From 4a103c94c14cbb7496be1fbf1433da6988f97392 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 22 Apr 2025 10:47:17 +0000 Subject: [PATCH 07/72] android, desktop: fix item statuses updates (#5840) --- .../chat/simplex/common/model/ChatModel.kt | 41 ++++++++++++------- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../common/views/chat/ChatItemsLoader.kt | 1 - .../views/chatlist/ChatListNavLinkView.kt | 1 - .../common/views/newchat/AddGroupView.kt | 1 - 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 76f984a396..2d4987e63b 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 @@ -301,7 +301,6 @@ object ChatModel { * If you use api call to get the items, use just [add] instead of [addToChatItems]. * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ val chatItems = mutableStateOf(SnapshotStateList()) - val chatItemStatuses = mutableMapOf() // set listener here that will be notified on every add/delete of a chat item val chatState = ActiveChatState() @@ -539,15 +538,16 @@ object ChatModel { val items = chatItems.value val itemIndex = items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { - items[itemIndex] = cItem - } else { - val status = chatItemStatuses.remove(cItem.id) - val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { - cItem.copy(meta = cItem.meta.copy(itemStatus = status)) + val oldStatus = items[itemIndex].meta.itemStatus + val newStatus = cItem.meta.itemStatus + val ci = if (shouldKeepOldSndCIStatus(oldStatus, newStatus)) { + cItem.copy(meta = cItem.meta.copy(itemStatus = oldStatus)) } else { cItem } - addToChatItems(ci) + items[itemIndex] = ci + } else { + addToChatItems(cItem) itemAdded = true } } @@ -556,10 +556,6 @@ object ChatModel { } suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) { - // TODO [knocking] see diff: - // why chatItemStatuses gets updated even if chatId doesn't match? - // should chatItemBelongsToScope replace chatId check? - // should chatItemStatuses be updated only if chatItemBelongsToScope (as in changed code)? this seems to make more sense withContext(Dispatchers.Main) { if (chatItemBelongsToScope(cInfo, cItem)) { val items = chatItems.value @@ -567,9 +563,6 @@ object ChatModel { if (itemIndex >= 0) { items[itemIndex] = cItem } - if (status != null) { - chatItemStatuses[cItem.id] = status - } } } } @@ -657,7 +650,6 @@ object ChatModel { } // clear current chat if (chatId.value == cInfo.id) { - chatItemStatuses.clear() chatItems.clearAndNotify() } } @@ -3082,6 +3074,18 @@ sealed class CIStatus { @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus() + fun isSent(): Boolean = when(this) { + is SndNew -> false + is SndSent -> true + is SndRcvd -> false + is SndErrorAuth -> true + is CISSndError -> true + is SndWarning -> true + is RcvNew -> false + is RcvRead -> false + is Invalid -> false + } + fun statusIcon( primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary, @@ -3121,6 +3125,13 @@ sealed class CIStatus { } } +fun shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus): Boolean = + when { + oldStatus is CIStatus.SndRcvd && newStatus !is CIStatus.SndRcvd -> true + oldStatus.isSent() && newStatus is CIStatus.SndNew -> true + else -> false + } + @Serializable sealed class SndError { @Serializable @SerialName("auth") class Auth: SndError() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 90f8259fb8..a8ae4f8302 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2588,10 +2588,10 @@ object ChatController { val cItem = chatItem.chatItem if (!cItem.isDeletedContent && active(r.user)) { withContext(Dispatchers.Main) { - chatModel.chatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + chatModel.chatsContext.upsertChatItem(rhId, cInfo, cItem) } withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, cItem) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index f6f5b6fcb7..07cdc065a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -68,7 +68,6 @@ suspend fun processLoadedChat( } } withContext(Dispatchers.Main) { - chatsCtx.chatItemStatuses.clear() chatsCtx.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.id splits.value = newSplits 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 b6b7a1aa64..b70a4e00d1 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 @@ -236,7 +236,6 @@ suspend fun openChat( suspend fun openLoadedChat(chat: Chat) { withContext(Dispatchers.Main) { - chatModel.chatsContext.chatItemStatuses.clear() chatModel.chatsContext.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id chatModel.chatsContext.chatState.clear() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 3d913cf957..2db40f770d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -45,7 +45,6 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId = rhId, groupInfo) chatModel.chatsContext.chatItems.clearAndNotify() - chatModel.chatsContext.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } setGroupMembers(rhId, groupInfo, chatModel) From ef12bb9a6c1c6abedcfde05042addec39dd49512 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:01:37 +0000 Subject: [PATCH 08/72] android, desktop: knocking UI improvements and fixes (#5841) --- .../chat/simplex/common/model/ChatModel.kt | 3 +-- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +++- .../simplex/common/views/chat/ChatView.kt | 11 +++++----- .../simplex/common/views/chat/ComposeView.kt | 22 +++++++++++-------- .../views/chat/SelectableChatItemToolbars.kt | 8 +++---- .../views/chat/group/GroupChatInfoView.kt | 15 +++++++------ .../views/chat/group/GroupMemberInfoView.kt | 5 ++++- .../views/chat/group/GroupReportsView.kt | 12 ++++++++-- .../views/chat/group/MemberSupportChatView.kt | 9 +++++++- .../views/chat/group/MemberSupportView.kt | 12 +++++----- .../commonMain/resources/MR/base/strings.xml | 2 ++ 11 files changed, 65 insertions(+), 38 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 2d4987e63b..3a84f225cc 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 @@ -376,7 +376,7 @@ object ChatModel { updateContact(rhId, updatedContact) } - suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null)) // TODO [knocking] review + suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null)) private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { if (hasChat(rhId, cInfo.id)) { @@ -567,7 +567,6 @@ object ChatModel { } } - // TODO [knocking] why does this function not use `withContext(Dispatchers.Main) { ... }` ? fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { // update chat list if (cInfo.groupChatScope() == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index a8ae4f8302..b8f57259bf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -191,6 +191,7 @@ class AppPreferences { val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false) val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) + val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true) val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) val appUpdateChannel = mkEnumPreference(SHARED_PREFS_APP_UPDATE_CHANNEL, AppUpdatesChannel.DISABLED) { AppUpdatesChannel.entries.firstOrNull { it.name == this } } val appSkippedUpdate = mkStrPreference(SHARED_PREFS_APP_SKIPPED_UPDATE, "") @@ -273,6 +274,7 @@ class AppPreferences { liveMessageAlertShown to false, showHiddenProfilesNotice to true, showMuteProfileAlert to true, + showReportsInSupportChatAlert to true, showDeleteConversationNotice to true, showDeleteContactNotice to true, ) @@ -431,6 +433,7 @@ class AppPreferences { private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown" private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown" private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert" + private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert" private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase" private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase" @@ -2711,7 +2714,6 @@ object ChatController { } is CR.MemberAcceptedByOther -> if (active(r.user)) { - // TODO [knocking] update secondary context? withContext(Dispatchers.Main) { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } 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 d3dad36d62..a440266958 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 @@ -124,7 +124,7 @@ fun ChatView( val supportChatInfo = ChatInfo.Group(chatInfo.groupInfo, groupChatScope = scopeInfo) showMemberSupportChatView( chatModel.chatId, - scrollToItemId = mutableStateOf(null), + scrollToItemId, supportChatInfo, scopeInfo ) @@ -371,7 +371,7 @@ fun ChatView( } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, close, close) } } } @@ -806,7 +806,7 @@ fun ChatLayout( } } } - if (chatsCtx.contentTag == MsgContentTag.Report) { // TODO [knocking] similar bar for support chats, without archiveItems? + if (chatsCtx.contentTag == MsgContentTag.Report) { Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -1322,8 +1322,8 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { - // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling - if (chatsCtx.contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } + // In secondary chat just set the itemId to scroll to so the main ChatView will handle scrolling + if (chatsCtx.contentTag == MsgContentTag.Report || chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext) return@remember { scrollToItemId.value = it } scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } @@ -2167,7 +2167,6 @@ private fun FloatingDate( } } -// TODO [knocking] same for member support chats? @Composable private fun SaveReportsStateOnDispose(chatsCtx: ChatModel.ChatsContext, listState: State) { DisposableEffect(Unit) { 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 0f96a551d1..f377540a95 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 @@ -568,17 +568,21 @@ fun ComposeView( } } + fun showReportsInSupportChatAlert() { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_sent_alert_title), + text = generalGetString(MR.strings.report_sent_alert_msg_view_in_support_chat), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.dont_show_again), + onDismiss = { + chatModel.controller.appPrefs.showReportsInSupportChatAlert.set(false) + }, + ) + } + suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) - if (cItems != null) { - // TODO [knocking] create report chat items in support scope - withContext(Dispatchers.Main) { - cItems.forEach { chatItem -> - chatsCtx.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) - } - } - } - + if (chatModel.controller.appPrefs.showReportsInSupportChatAlert.get()) showReportsInSupportChatAlert() return cItems?.map { it.chatItem } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index c934b6d5d7..ac722783a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -101,21 +101,21 @@ fun SelectedItemsButtonsToolbar( ) } - IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value) { + IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value && chatsCtx.secondaryContextFilter == null) { Icon( painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), - tint = if (!moderateEnabled.value || deleteCountProhibited.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error + tint = if (!moderateEnabled.value || deleteCountProhibited.value || chatsCtx.secondaryContextFilter != null) MaterialTheme.colors.secondary else MaterialTheme.colors.error ) } - IconButton({ forwardItems() }, enabled = forwardEnabled.value && !forwardCountProhibited.value) { + IconButton({ forwardItems() }, enabled = forwardEnabled.value && !forwardCountProhibited.value && chatsCtx.secondaryContextFilter == null) { Icon( painterResource(MR.images.ic_forward), null, Modifier.size(22.dp), - tint = if (!forwardEnabled.value || forwardCountProhibited.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + tint = if (!forwardEnabled.value || forwardCountProhibited.value || chatsCtx.secondaryContextFilter != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } } 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 aeb4041008..a28aca2236 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 @@ -78,8 +78,6 @@ fun ModalData.GroupChatInfoView( .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole } - Log.e(TAG, "######### GroupChatInfoView chatModel.groupMembers length = ${chatModel.groupMembers.value.count()}") - GroupChatInfoLayout( chat, groupInfo, @@ -129,7 +127,7 @@ fun ModalData.GroupChatInfoView( } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, stats, code, chatModel, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, closeCurrent) { closeCurrent() close() } @@ -148,6 +146,7 @@ fun ModalData.GroupChatInfoView( MemberSupportView( chat, groupInfo, + scrollToItemId, close ) } @@ -330,9 +329,11 @@ fun AddGroupMembersButton( } @Composable -fun UserSupportChatButton(groupInfo: GroupInfo) { +fun UserSupportChatButton( + groupInfo: GroupInfo, + scrollToItemId: MutableState +) { val scope = rememberCoroutineScope() - val scrollToItemId: MutableState = remember { mutableStateOf(null) } SettingsActionItem( painterResource(MR.images.ic_flag), @@ -463,7 +464,7 @@ fun ModalData.GroupChatInfoLayout( SectionView { if (groupInfo.membership.supportChat != null) { anyTopSectionRowShow = true - UserSupportChatButton(groupInfo) + UserSupportChatButton(groupInfo, scrollToItemId) } if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { anyTopSectionRowShow = true @@ -738,7 +739,7 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { @Composable private fun MemberSupportButton(onClick: () -> Unit) { SettingsActionItem( - painterResource(MR.images.ic_flag), // TODO [knocking] change icon + painterResource(MR.images.ic_flag), stringResource(MR.strings.member_support), click = onClick ) 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 a6c21574ef..7a3bd60e14 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 @@ -45,6 +45,7 @@ fun GroupMemberInfoView( rhId: Long?, groupInfo: GroupInfo, member: GroupMember, + scrollToItemId: MutableState, connectionStats: ConnectionStats?, connectionCode: String?, chatModel: ChatModel, @@ -79,6 +80,7 @@ fun GroupMemberInfoView( rhId = rhId, groupInfo, member, + scrollToItemId, connStats, newRole, developerTools, @@ -269,6 +271,7 @@ fun GroupMemberInfoLayout( rhId: Long?, groupInfo: GroupInfo, member: GroupMember, + scrollToItemId: MutableState, connStats: MutableState, newRole: MutableState, developerTools: Boolean, @@ -302,7 +305,6 @@ fun GroupMemberInfoLayout( @Composable fun SupportChatButton() { val scope = rememberCoroutineScope() - val scrollToItemId: MutableState = remember { mutableStateOf(null) } // TODO [knocking] scroll to report from support chat? SettingsActionItem( painterResource(MR.images.ic_flag), @@ -908,6 +910,7 @@ fun PreviewGroupMemberInfoLayout() { rhId = null, groupInfo = GroupInfo.sampleData, member = GroupMember.sampleData, + scrollToItemId = remember { mutableStateOf(null) }, connStats = remember { mutableStateOf(null) }, newRole = remember { mutableStateOf(GroupMemberRole.Member) }, developerTools = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index bb05ede81b..2cc2402c0a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -15,7 +15,15 @@ import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.* @Composable -private fun GroupReportsView(reportsChatsCtx: ChatModel.ChatsContext, staleChatId: State, scrollToItemId: MutableState) { +private fun GroupReportsView( + reportsChatsCtx: ChatModel.ChatsContext, + staleChatId: State, + scrollToItemId: MutableState, + close: () -> Unit +) { + KeyChangeEffect(chatModel.chatId.value) { + close() + } ChatView(reportsChatsCtx, staleChatId, scrollToItemId, onComposed = {}) } @@ -75,7 +83,7 @@ suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: Mu ModalView({}, showAppBar = false) { val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) { - GroupReportsView(reportsChatsCtx, staleChatId, scrollToItemId) + GroupReportsView(reportsChatsCtx, staleChatId, scrollToItemId, close) } else { LaunchedEffect(Unit) { close() 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 86bb9b7255..3196cae15c 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 @@ -21,7 +21,14 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -private fun MemberSupportChatView(memberSupportChatsCtx: ChatModel.ChatsContext, staleChatId: State, scrollToItemId: MutableState) { +private fun MemberSupportChatView( + memberSupportChatsCtx: ChatModel.ChatsContext, + staleChatId: State, + scrollToItemId: MutableState +) { + KeyChangeEffect(chatModel.chatId.value) { + ModalManager.end.closeModals() + } ChatView(memberSupportChatsCtx, staleChatId, scrollToItemId, onComposed = {}) } 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 55c2126771..bd0e866c03 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 @@ -34,15 +34,17 @@ import kotlinx.coroutines.launch fun ModalData.MemberSupportView( chat: Chat, groupInfo: GroupInfo, + scrollToItemId: MutableState, close: () -> Unit ) { - KeyChangeEffect(chat.id) { - close() + KeyChangeEffect(chatModel.chatId.value) { + ModalManager.end.closeModals() } ModalView(close = close) { MemberSupportViewLayout( chat, - groupInfo + groupInfo, + scrollToItemId ) } } @@ -50,11 +52,11 @@ fun ModalData.MemberSupportView( @Composable private fun ModalData.MemberSupportViewLayout( chat: Chat, - groupInfo: GroupInfo + groupInfo: GroupInfo, + scrollToItemId: MutableState ) { val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } val scope = rememberCoroutineScope() - val scrollToItemId: MutableState = remember { mutableStateOf(null) } // TODO [knocking] scroll to report from support chat? val membersWithChats = remember { chatModel.groupMembers }.value .filter { it.supportChat != null && it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } 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 53ae1abb50..41cde59499 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -509,6 +509,8 @@ Report violation: only group moderators will see it. Report content: only group moderators will see it. Report other: only group moderators will see it. + Report sent to moderators + You can view your reports in Support Chat. Image From b9adc7b3457e706894a889eb761b4be35cbed740 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 23 Apr 2025 08:04:54 +0000 Subject: [PATCH 09/72] android, desktop: member support list updates, refresh button (#5842) --- .../views/chat/group/GroupChatInfoView.kt | 1 + .../views/chat/group/MemberSupportView.kt | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) 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 a28aca2236..beb7539171 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 @@ -144,6 +144,7 @@ fun ModalData.GroupChatInfoView( openMemberSupport = { ModalManager.end.showCustomModal { close -> MemberSupportView( + rhId, chat, groupInfo, scrollToItemId, 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 bd0e866c03..1b7ee1c4d6 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 @@ -25,13 +25,16 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.chatlist.unreadCountStr +import chat.simplex.common.views.newchat.AddContactLearnMore import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch @Composable fun ModalData.MemberSupportView( + rhId: Long?, chat: Chat, groupInfo: GroupInfo, scrollToItemId: MutableState, @@ -40,7 +43,13 @@ fun ModalData.MemberSupportView( KeyChangeEffect(chatModel.chatId.value) { ModalManager.end.closeModals() } - ModalView(close = close) { + LaunchedEffect(Unit) { + setGroupMembers(rhId, groupInfo, chatModel) + } + ModalView( + close = close, + endButtons = { RefreshMembersButton(rhId, groupInfo) } + ) { MemberSupportViewLayout( chat, groupInfo, @@ -49,6 +58,26 @@ fun ModalData.MemberSupportView( } } +@Composable +fun RefreshMembersButton( + rhId: Long?, + groupInfo: GroupInfo +) { + IconButton( + onClick = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + } + } + ) { + Icon( + painterResource(MR.images.ic_refresh), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } +} + @Composable private fun ModalData.MemberSupportViewLayout( chat: Chat, From 5262b8caca178b4481385bf08308f3065a830c85 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:15:17 +0000 Subject: [PATCH 10/72] core: support chat unread stats (#5844) --- .../chat/simplex/common/model/ChatModel.kt | 24 +++ .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 85 ++++++-- .../views/chat/group/MemberSupportView.kt | 1 - .../common/views/chatlist/ChatPreviewView.kt | 14 ++ .../commonMain/resources/MR/base/strings.xml | 2 + src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Store/Messages.hs | 41 +++- .../SQLite/Migrations/chat_query_plans.txt | 203 ++++++++++++++---- 9 files changed, 300 insertions(+), 77 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 3a84f225cc..a45d384638 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 @@ -898,6 +898,28 @@ object ChatModel { } } + fun increaseGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId) { + changeGroupSupportChatsUnreadCounter(rhId, chatId, 1) + } + + fun decreaseGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId, by: Int = 1) { + changeGroupSupportChatsUnreadCounter(rhId, chatId, -by) + } + + private fun changeGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { + if (by == 0) return + + val i = getChatIndex(rhId, chatId) + if (i >= 0) { + val chat = chats.value[i] + chats[i] = chat.copy( + chatStats = chat.chatStats.copy( + supportChatsUnreadCount = (chat.chatStats.supportChatsUnreadCount + by).coerceAtLeast(0), + ) + ) + } + } + fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { changeGroupReportsCounter(rhId, chatId, 1) } @@ -1315,6 +1337,8 @@ data class Chat( val unreadMentions: Int = 0, // actual only via getChats() and getChat(.initial), otherwise, zero val reportsCount: Int = 0, + // actual only via getChats() and getChat(.initial), otherwise, zero + val supportChatsUnreadCount: Int = 0, val minUnreadItemId: Long = 0, // actual only via getChats(), otherwise, false val unreadChat: Boolean = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b8f57259bf..78e4e0edfc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2559,10 +2559,12 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.addChatItem(rhId, cInfo, cItem) - // TODO [knocking] increase support chats unread count; move `isActiveReport` checks inside model functions? if (cItem.isActiveReport) { chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } + if (cInfo.groupChatScope() != null && cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { + chatModel.chatsContext.increaseGroupSupportChatsUnreadCounter(rhId, cInfo.id) + } } withContext(Dispatchers.Main) { chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) 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 a440266958..58a019de1e 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 @@ -63,6 +63,7 @@ fun ChatView( onComposed: suspend (chatId: String) -> Unit ) { val showSearch = rememberSaveable { mutableStateOf(false) } + val chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } val activeChatInfo = remember { derivedStateOf { @@ -78,7 +79,7 @@ fun ChatView( } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value - if (chatInfo == null || user == null) { + if (chat == null || chatInfo == null || user == null) { LaunchedEffect(Unit) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -139,6 +140,7 @@ fun ChatView( chatsCtx.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } + val reportsCount = reportsCount(chatInfo.id) val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), @@ -340,7 +342,7 @@ fun ChatView( } } }, - showGroupReports = { + showReportsOrSupportChats = { val info = activeChatInfo.value ?: return@ChatLayout if (ModalManager.end.hasModalsOpen()) { ModalManager.end.closeModals() @@ -348,7 +350,30 @@ fun ChatView( } hideKeyboard(view) scope.launch { - showGroupReportsView(staleChatId, scrollToItemId, info) + if (reportsCount > 0) { + showGroupReportsView(staleChatId, scrollToItemId, info) + } else if (info is ChatInfo.Group && info.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ModalManager.end.showCustomModal { close -> + MemberSupportView( + chatRh, + chat, + info.groupInfo, + scrollToItemId, + close + ) + } + } else if (info is ChatInfo.Group) { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(info.groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + } } }, showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> @@ -599,6 +624,9 @@ fun ChatView( withBGApi { withContext(Dispatchers.Main) { chatModel.chatsContext.markChatItemsRead(chatRh, chatInfo.id, itemsIds) + if (chatsCtx.secondaryContextFilter != null) { + chatModel.chatsContext.decreaseGroupSupportChatsUnreadCounter(chatRh, chatInfo.id) + } ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatItemsRead( chatRh, @@ -705,7 +733,7 @@ fun ChatLayout( selectedChatItems: MutableState?>, back: () -> Unit, info: () -> Unit, - showGroupReports: () -> Unit, + showReportsOrSupportChats: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -856,9 +884,10 @@ fun ChatLayout( } } val reportsCount = reportsCount(chatInfo?.id) + val supportChatsUnreadCount = supportChatsUnreadCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (chatsCtx.secondaryContextFilter == null && reportsCount > 0) { // TODO [knocking] support chats unread count toolbar - ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) + if (chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0)) { + SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = true, showReportsOrSupportChats) } else { StatusBarBackground() } @@ -914,8 +943,8 @@ fun ChatLayout( SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { - ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) + if ((reportsCount > 0 || supportChatsUnreadCount > 0) && (!oneHandUI.value || !chatBottomBar.value)) { + SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = false, showReportsOrSupportChats) } } } @@ -1147,10 +1176,11 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo } @Composable -private fun ReportedCountToolbar( +private fun SupportChatsCountToolbar( reportsCount: Int, + supportChatsUnreadCount: Int, withStatusBar: Boolean, - showGroupReports: () -> Unit + showReportsOrSupportChats: () -> Unit ) { Box { val statusBarPadding = if (withStatusBar) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else 0.dp @@ -1159,18 +1189,25 @@ private fun ReportedCountToolbar( .fillMaxWidth() .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) .background(MaterialTheme.colors.background) - .clickable(onClick = showGroupReports) + .clickable(onClick = showReportsOrSupportChats) .padding(top = statusBarPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) + val iconColor = if (reportsCount == 0) MaterialTheme.colors.primary else MaterialTheme.colors.error + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = iconColor) Spacer(Modifier.width(4.dp)) Text( - if (reportsCount == 1) { - stringResource(MR.strings.group_reports_active_one) + if (supportChatsUnreadCount == 0) { + if (reportsCount == 1) { + stringResource(MR.strings.group_reports_active_one) + } else { + stringResource(MR.strings.group_reports_active).format(reportsCount) + } + } else if (reportsCount == 0) { + stringResource(MR.strings.group_new_support_messages).format(supportChatsUnreadCount) } else { - stringResource(MR.strings.group_reports_active).format(reportsCount) + String.format(generalGetString(MR.strings.group_reports_active_new_support_messages), reportsCount, supportChatsUnreadCount) }, style = MaterialTheme.typography.button ) @@ -1255,9 +1292,10 @@ fun BoxScope.ChatItemsList( } val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } val reportsCount = reportsCount(chatInfo.id) + val supportChatsUnreadCount = supportChatsUnreadCount(chatInfo.id) val topPaddingToContent = topPaddingToContent( chatView = chatsCtx.secondaryContextFilter == null, - additionalTopBar = chatsCtx.secondaryContextFilter == null && reportsCount > 0 // TODO [knocking] && support chats unread count > 0 ? + additionalTopBar = chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0) ) val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) val numberOfBottomAppBars = numberOfBottomAppBars() @@ -1597,7 +1635,7 @@ fun BoxScope.ChatItemsList( ), reverseLayout = true, additionalBarOffset = composeViewHeight, - additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && reportsCount > 0), // TODO [knocking] && support chats unread count > 0 ? + additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0)), chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -2282,6 +2320,15 @@ fun reportsCount(staleChatId: String?): Int { } } +@Composable +fun supportChatsUnreadCount(staleChatId: String?): Int { + return if (staleChatId?.startsWith("#") != true) { + 0 + } else { + remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId }?.chatStats } }.value?.supportChatsUnreadCount ?: 0 + } +} + private fun reversedChatItemsStatic(chatsCtx: ChatModel.ChatsContext): List = chatsCtx.chatItems.value.asReversed() @@ -2980,7 +3027,7 @@ fun PreviewChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, - showGroupReports = {}, + showReportsOrSupportChats = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, @@ -3058,7 +3105,7 @@ fun PreviewGroupChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, - showGroupReports = {}, + showReportsOrSupportChats = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, 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 1b7ee1c4d6..d69b6728b9 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 @@ -27,7 +27,6 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.chatlist.unreadCountStr -import chat.simplex.common.views.newchat.AddContactLearnMore import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch 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 93d512507a..584a358c5e 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 @@ -364,6 +364,8 @@ fun ChatPreviewView( progressView() } else if (chat.chatStats.reportsCount > 0) { GroupReportsIcon() + } else if (chat.chatStats.supportChatsUnreadCount > 0) { + GroupSupportUnreadIcon() } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -559,6 +561,18 @@ fun GroupReportsIcon() { ) } +@Composable +fun GroupSupportUnreadIcon() { + Icon( + painterResource(MR.images.ic_flag), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(21.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) +} + @Composable private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { return if (groupInfo.membership.memberIncognito) 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 41cde59499..a3f7ae9dfd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -465,6 +465,8 @@ 1 report %d reports Member reports + %d new support messages + %1$d reports, %2$d new support messages Share message… diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 00dd999139..8aa595bed8 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -350,13 +350,14 @@ data ChatStats = ChatStats { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API unreadMentions :: Int, -- returned both in /_get chat initial API and in /_get chats API reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API + supportChatsUnreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API minUnreadItemId :: ChatItemId, unreadChat :: Bool } deriving (Show) emptyChatStats :: ChatStats -emptyChatStats = ChatStats 0 0 0 0 False +emptyChatStats = ChatStats 0 0 0 0 0 False data NavigationInfo = NavigationInfo { afterUnread :: Int, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d1daace916..d0fb2bfa35 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -632,15 +632,17 @@ data ChatPreviewData (c :: ChatType) where data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreviewData c) -type ChatStatsRow = (Int, Int, ChatItemId, BoolInt) +type ChatStatsRow = (Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, unreadMentions = 0, reportsCount, minUnreadItemId, unreadChat} +toChatStats (unreadCount, minUnreadItemId, BI unreadChat) = + ChatStats {unreadCount, unreadMentions = 0, reportsCount = 0, supportChatsUnreadCount = 0, minUnreadItemId, unreadChat} -type GroupStatsRow = (Int, Int, Int, ChatItemId, BoolInt) +type GroupStatsRow = (Int, Int, Int, Int, ChatItemId, BoolInt) toGroupStats :: GroupStatsRow -> ChatStats -toGroupStats (unreadCount, unreadMentions, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat} +toGroupStats (unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, BI unreadChat) = + ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -662,7 +664,6 @@ findDirectChatPreviews_ db User {userId} pagination clq = LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -762,13 +763,14 @@ findGroupChatPreviews_ db User {userId} pagination clq = COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -778,8 +780,16 @@ findGroupChatPreviews_ db User {userId} pagination clq = AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id |] - baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False) + baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False, userId, CISRcvNew) getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> do let q = baseQuery <> " WHERE g.user_id = ?" @@ -866,7 +876,6 @@ findLocalChatPreviews_ db User {userId} pagination clq = LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -1485,14 +1494,16 @@ getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do where getStats minUnreadItemId (unreadCount, unreadMentions) = do reportsCount <- getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} + supportChatsUnreadCount <- getGroupSupportChatsUnreadCount_ db user g + pure ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat = False} getGroupStats_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> ExceptT StoreError IO ChatStats getGroupStats_ db user g scopeInfo_ = do minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g scopeInfo_ Nothing (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g scopeInfo_ Nothing reportsCount <- liftIO $ getGroupReportsCount_ db user g False - pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} + supportChatsUnreadCount <- liftIO $ getGroupSupportChatsUnreadCount_ db user g + pure ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat = False} getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) getGroupMinUnreadId_ db user g scopeInfo_ contentFilter = @@ -1506,7 +1517,7 @@ getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatSco getGroupUnreadCount_ db user g scopeInfo_ contentFilter = head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery "" where - baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? " + baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL " getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = @@ -1516,6 +1527,14 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" (userId, groupId, MCReport_, BI archived) +getGroupSupportChatsUnreadCount_ :: DB.Connection -> User -> GroupInfo -> IO Int +getGroupSupportChatsUnreadCount_ db User {userId} GroupInfo {groupId} = + fromOnly . head + <$> DB.query + db + "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NOT NULL AND item_status = ?" + (userId, groupId, CISRcvNew) + queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> ExceptT StoreError IO [r] queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter baseQuery orderLimit = case (scopeInfo_, contentFilter) of 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 95795c6aa7..3b63f346d5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1584,7 +1584,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1625,7 +1624,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1660,7 +1658,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1694,7 +1691,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1728,7 +1724,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1762,7 +1757,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1796,7 +1790,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1830,7 +1823,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1864,7 +1856,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1895,7 +1886,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1926,7 +1916,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct @@ -1959,13 +1948,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -1975,6 +1965,14 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE g.user_id = ? @@ -1987,13 +1985,16 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2011,13 +2012,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -2027,6 +2029,14 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.favorite = 1 @@ -2034,12 +2044,15 @@ Query: ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2057,13 +2070,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -2073,18 +2087,29 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts 0) AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2147,13 +2184,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -2163,18 +2201,29 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2192,13 +2241,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -2208,18 +2258,29 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.favorite = 1 AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2282,13 +2355,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -2298,18 +2372,29 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.favorite = 1 ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2327,13 +2412,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -2343,15 +2429,26 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2411,13 +2520,14 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), + COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, SUM(user_mention) as UnreadMentions, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? + WHERE user_id = ? AND group_id IS NOT NULL AND group_scope_tag IS NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN ( @@ -2427,15 +2537,26 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND group_scope_tag IS NOT NULL + AND item_status = ? + GROUP BY group_id + ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) +MATERIALIZE SupportChatsUnreadCount +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN +SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2451,7 +2572,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2487,7 +2607,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2522,7 +2641,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2557,7 +2675,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2592,7 +2709,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2627,7 +2743,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2662,7 +2777,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2697,7 +2811,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2729,7 +2842,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -2761,7 +2873,6 @@ Query: LIMIT 1 ) AS chat_item_id, COALESCE(ChatStats.UnreadCount, 0), - 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf @@ -5527,6 +5638,10 @@ Query: SELECT COUNT(1) FROM chat_item_versions WHERE chat_item_id = ? Plan: SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NOT NULL AND item_status = ? +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) + Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) @@ -5539,7 +5654,7 @@ Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) -Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? +Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) From 18fa5dbf04eee2a16e0eb11eb30c8980fd7d9836 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 24 Apr 2025 16:29:27 +0000 Subject: [PATCH 11/72] core, ui: admission message (#5848) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 4 +++- .../simplex/common/views/chat/item/ChatItemView.kt | 14 +++++++++++++- .../src/commonMain/resources/MR/base/strings.xml | 1 + src/Simplex/Chat/Library/Internal.hs | 9 ++++++++- src/Simplex/Chat/Library/Subscriber.hs | 10 ++++++---- src/Simplex/Chat/Messages/CIContent.hs | 6 ++++++ src/Simplex/Chat/Messages/CIContent/Events.hs | 1 + src/Simplex/Chat/Store/Groups.hs | 6 ++++++ .../Store/SQLite/Migrations/chat_query_plans.txt | 4 ++++ 9 files changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a45d384638..8cdac06333 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 @@ -2569,7 +2569,7 @@ data class ChatItem ( else -> CIMergeCategory.RcvGroupEvent } is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { - is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.UserPendingReview -> null else -> CIMergeCategory.SndGroupEvent } else -> { @@ -4349,6 +4349,7 @@ sealed class SndGroupEvent() { @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent() @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent() + @Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent() val text: String get() = when (this) { is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text) @@ -4361,6 +4362,7 @@ sealed class SndGroupEvent() { is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName) is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left) is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated) + is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review) } } 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 3ea75a74a7..1f4615d5fe 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 @@ -636,6 +636,15 @@ fun ChatItemView( CIEventView(eventItemViewText(reversedChatItems)) } + @Composable fun UserPendingReviewEventItemView() { + Text( + buildAnnotatedString { + withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } + @Composable fun DeletedItem() { MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) @@ -717,7 +726,10 @@ fun ChatItemView( MsgContentItemDropdownMenu() } is CIContent.SndGroupEventContent -> { - EventItemView() + when (c.sndGroupEvent) { + is SndGroupEvent.UserPendingReview -> UserPendingReviewEventItemView() + else -> EventItemView() + } MsgContentItemDropdownMenu() } is CIContent.RcvConnEventContent -> { 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 a3f7ae9dfd..d9f789a202 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1594,6 +1594,7 @@ you removed %1$s you left group profile updated + Please wait for group moderators to review your request to join the group. %s connected %s and %s connected diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 13e58eb36d..66cc5c1767 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -967,10 +967,17 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ExistingIncognito lp -> fromLocalProfile lp introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToModerators vr user gInfo m = do +introduceToModerators vr user gInfo@GroupInfo {groupId} m = do + when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ sendPendingReviewMessage modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo let rcpModMs = filter memberCurrent modMs introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) + where + sendPendingReviewMessage = case memberConn m of + Just conn -> do + let event = XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing + sendGroupMemberMessages user conn [event] groupId + Nothing -> pure () introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToAll vr user gInfo m = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 41d7bdad72..35b6a0b822 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -796,9 +796,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (connChatVersion < batchSend2Version) sendGroupAutoReply case mStatus of GSMemPendingApproval -> pure () - -- edge case: reviews were turned off mid connection; - -- options: proceed to review as declared, or introduce to group and send XGrpLinkAcpt; - -- choosing first option for simplicity, same edge case for approval is also not considered GSMemPendingReview -> introduceToModerators vr user gInfo' m' _ -> do introduceToAll vr user gInfo' m' @@ -2470,7 +2467,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (announcedMember', Just scopeInfo) xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> CM () - xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do + xGrpMemIntro gInfo@GroupInfo {membership, 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 @@ -2481,6 +2478,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" Just (ChatVersionRange mcvr) | maxVersion mcvr >= groupDirectInvVersion -> do + memCount <- withStore' $ \db -> getGroupMembersCount db user gInfo + -- only create SGEUserPendingReview item on the first introduction - when only 2 members are user and host + when (memberPending membership && memCount == 2) $ do + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent SGEUserPendingReview) Nothing 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 diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 886639aa07..9e93db39b5 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -341,6 +341,12 @@ sndGroupEventToText = \case SGEMemberDeleted _ p -> "removed " <> profileToText p SGEUserLeft -> "left" SGEGroupUpdated _ -> "group profile updated" + SGEUserPendingReview -> "please wait for group moderators to review your request to join the group" + +-- used to send to members with old version +pendingReviewMessage :: Text +pendingReviewMessage = + "Please wait for group moderators to review your request to join the group." rcvConnEventToText :: RcvConnEvent -> Text rcvConnEventToText = \case diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index ca076e5b8a..36b3a6ebdd 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -40,6 +40,7 @@ data SndGroupEvent | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMembers | SGEUserLeft -- CRLeftMemberUser | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + | SGEUserPendingReview deriving (Show) data RcvConnEvent diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 24f598a7e6..2e82b1dc27 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -56,6 +56,7 @@ module Simplex.Chat.Store.Groups getGroupMembers, getGroupModerators, getGroupMembersForExpiration, + getGroupMembersCount, getGroupCurrentMembersCount, deleteGroupChatItems, deleteGroupMembers, @@ -944,6 +945,11 @@ toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectio toContactMember vr User {userContactId} (memberRow :. connRow) = (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} +getGroupMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int +getGroupMembersCount db User {userId} GroupInfo {groupId} = + fromOnly . head + <$> DB.query db "SELECT COUNT(1) FROM group_members WHERE group_id = ? AND user_id = ?" (groupId, userId) + getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do statuses :: [GroupMemberStatus] <- 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 3b63f346d5..743bfc53bf 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5650,6 +5650,10 @@ Query: SELECT COUNT(1) FROM contacts WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) +Query: SELECT COUNT(1) FROM group_members WHERE group_id = ? AND user_id = ? +Plan: +SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) + Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) From 60bfd9933a20a3f1ee0fa71a5ad72ab4b2b0f489 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 28 Apr 2025 06:28:40 +0000 Subject: [PATCH 12/72] core: correctly communicate group acceptance state (#5852) --- .../src/Directory/Service.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 4 +- src/Simplex/Chat/Library/Internal.hs | 15 ++++--- src/Simplex/Chat/Library/Subscriber.hs | 41 ++++++++++--------- src/Simplex/Chat/Protocol.hs | 6 +-- src/Simplex/Chat/Store/Groups.hs | 6 --- .../SQLite/Migrations/chat_query_plans.txt | 4 -- src/Simplex/Chat/Types.hs | 3 +- src/Simplex/Chat/Types/Shared.hs | 8 ++-- src/Simplex/Chat/View.hs | 5 ++- tests/ChatTests/Groups.hs | 12 +++--- 11 files changed, 53 insertions(+), 53 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index e547a1e982..21f6f463a3 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -167,7 +167,7 @@ acceptMemberHook when (useMemberFilter img $ rejectNames a) checkName pure $ if - | useMemberFilter img (passCaptcha a) -> (GAPending, GRMember) + | useMemberFilter img (passCaptcha a) -> (GAPendingApproval, GRMember) | useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver) | otherwise -> (GAAccepted, memberRole) where diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 90648305d5..8980a345c0 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2062,7 +2062,7 @@ processChatCommand' vr = \case let m' = m {memberStatus = GSMemPendingReview} pure $ CRMemberAccepted user gInfo m' Nothing -> do - let msg = XGrpLinkAcpt role (memberId' m) + let msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendDirectMemberMessage mConn msg groupId introduceToRemaining vr user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m @@ -2073,7 +2073,7 @@ processChatCommand' vr = \case let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo let rcpModMs' = filter memberCurrent modMs - msg = XGrpLinkAcpt role (memberId' m) + msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m newMemberStatus role pure $ CRMemberAccepted user gInfo m' diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 66cc5c1767..46e8177e8e 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -967,17 +967,16 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ExistingIncognito lp -> fromLocalProfile lp introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () -introduceToModerators vr user gInfo@GroupInfo {groupId} m = do - when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ sendPendingReviewMessage +introduceToModerators vr 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 $ extMsgContent (MCText pendingReviewMessage) Nothing + void $ sendDirectMemberMessage mConn msg groupId modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo let rcpModMs = filter memberCurrent modMs introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) - where - sendPendingReviewMessage = case memberConn m of - Just conn -> do - let event = XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing - sendGroupMemberMessages user conn [event] groupId - Nothing -> pure () introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToAll vr user gInfo m = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 35b6a0b822..d44d11fb28 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -872,7 +872,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName XInfo p -> xInfoMember gInfo' m'' p brokerTs XGrpLinkMem p -> xGrpLinkMem gInfo' m'' conn' p - XGrpLinkAcpt role memberId -> xGrpLinkAcpt gInfo' m'' role memberId msg brokerTs + XGrpLinkAcpt acceptance role memberId -> xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> xGrpMemInv gInfo' m'' memId introInv @@ -2083,8 +2083,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatchingMemberContact m' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" - xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () - xGrpLinkAcpt gInfo@GroupInfo {membership} m role memberId msg brokerTs + xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () + xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs | sameMemberId memberId membership = processUserAccepted | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case @@ -2102,16 +2102,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just c | connReady c -> GSMemConnected _ -> GSMemAnnounced where - processUserAccepted = do - membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role - let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) - groupMsgToView gInfo scopeInfo ci - toView $ CRUserJoinedGroup user gInfo {membership = membership'} m - let cd = CDGroupRcv gInfo Nothing m - createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing - createGroupFeatureItems user cd CIRcvGroupFeature gInfo - maybeCreateGroupDescrLocal gInfo m + processUserAccepted = case acceptance of + GAAccepted -> do + membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) + groupMsgToView gInfo scopeInfo ci + toView $ CRUserJoinedGroup user gInfo {membership = membership'} m + let cd = CDGroupRcv gInfo Nothing m + createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing + createGroupFeatureItems user cd CIRcvGroupFeature gInfo + maybeCreateGroupDescrLocal gInfo m + GAPendingReview -> do + membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemPendingReview role + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + createInternalChatItem user (CDGroupSnd gInfo scopeInfo) (CISndGroupEvent SGEUserPendingReview) Nothing + toView $ CRMemberAcceptedByOther user gInfo m membership' + GAPendingApproval -> + messageWarning "x.grp.link.acpt: unexpected group acceptance - pending approval" introduceToRemainingMembers acceptedMember = do introduceToRemaining vr user gInfo acceptedMember when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo acceptedMember @@ -2467,7 +2475,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (announcedMember', Just scopeInfo) xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MemberRestrictions -> CM () - xGrpMemIntro gInfo@GroupInfo {membership, chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) memRestrictions = do + 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 @@ -2478,11 +2486,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" Just (ChatVersionRange mcvr) | maxVersion mcvr >= groupDirectInvVersion -> do - memCount <- withStore' $ \db -> getGroupMembersCount db user gInfo - -- only create SGEUserPendingReview item on the first introduction - when only 2 members are user and host - when (memberPending membership && memCount == 2) $ do - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent SGEUserPendingReview) Nothing 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 diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index e02a43f1cd..57383e7b11 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -346,7 +346,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json - XGrpLinkAcpt :: GroupMemberRole -> MemberId -> ChatMsgEvent 'Json + XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -1099,7 +1099,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" - XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "role" <*> p "memberId" + XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1163,7 +1163,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] - XGrpLinkAcpt role memberId -> o ["role" .= role, "memberId" .= memberId] + XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 2e82b1dc27..24f598a7e6 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -56,7 +56,6 @@ module Simplex.Chat.Store.Groups getGroupMembers, getGroupModerators, getGroupMembersForExpiration, - getGroupMembersCount, getGroupCurrentMembersCount, deleteGroupChatItems, deleteGroupMembers, @@ -945,11 +944,6 @@ toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectio toContactMember vr User {userContactId} (memberRow :. connRow) = (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} -getGroupMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int -getGroupMembersCount db User {userId} GroupInfo {groupId} = - fromOnly . head - <$> DB.query db "SELECT COUNT(1) FROM group_members WHERE group_id = ? AND user_id = ?" (groupId, userId) - getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do statuses :: [GroupMemberStatus] <- 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 743bfc53bf..3b63f346d5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5650,10 +5650,6 @@ Query: SELECT COUNT(1) FROM contacts WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) -Query: SELECT COUNT(1) FROM group_members WHERE group_id = ? AND user_id = ? -Plan: -SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) - Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 60bd409067..da999b96bf 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1050,7 +1050,8 @@ instance ToJSON GroupMemberStatus where acceptanceToStatus :: Maybe GroupMemberAdmission -> GroupAcceptance -> GroupMemberStatus acceptanceToStatus memberAdmission groupAcceptance - | groupAcceptance == GAPending = GSMemPendingApproval + | groupAcceptance == GAPendingApproval = GSMemPendingApproval + | groupAcceptance == GAPendingReview = GSMemPendingReview | (memberAdmission >>= review) == Just MCAll = GSMemPendingReview | otherwise = GSMemAccepted diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index 832d877234..60ebe9d033 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -49,15 +49,17 @@ instance ToJSON GroupMemberRole where toJSON = strToJSON toEncoding = strToJEncoding -data GroupAcceptance = GAAccepted | GAPending deriving (Eq, Show) +data GroupAcceptance = GAAccepted | GAPendingApproval | GAPendingReview deriving (Eq, Show) instance StrEncoding GroupAcceptance where strEncode = \case GAAccepted -> "accepted" - GAPending -> "pending" + GAPendingApproval -> "pending" + GAPendingReview -> "pending_review" strDecode = \case "accepted" -> Right GAAccepted - "pending" -> Right GAPending + "pending" -> Right GAPendingApproval + "pending_review" -> Right GAPendingReview r -> Left $ "bad GroupAcceptance " <> B.unpack r strP = strDecode <$?> A.takeByteString diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4864e273c3..8d2835541d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1121,7 +1121,10 @@ viewMemberAccepted g m@GroupMember {memberStatus} = case memberStatus of _ -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted"] viewMemberAcceptedByOther :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] -viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory} = case memberCategory of +viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory, memberStatus} = case memberCategory of + GCUserMember -> case memberStatus of + GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted you to the group, pending review"] + _ -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted you to the group [warning - unexpected]"] GCInviteeMember -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted " <> ttyMember m <> " to the group (will introduce remaining members)"] _ -> [ttyGroup' g <> ": " <> ttyMember acceptingMember <> " accepted " <> ttyMember m <> " to the group"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9bee2da58c..49acf71ecb 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3032,7 +3032,7 @@ testGLinkApproveMember = cath #> "#team hi group" [alice, bob] *<# "#team cath> hi group" where - cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPending, GRObserver))}} + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} testGLinkReviewMember :: HasCallStack => TestParams -> IO () testGLinkReviewMember = @@ -3062,7 +3062,8 @@ testGLinkReviewMember = concurrentlyN_ [ alice <## "#team: eve connected and pending review", eve - <### [ "#team: joining the group...", + <### [ "#team: alice accepted you to the group, pending review", + "#team: joining the group...", "#team: you joined the group, connecting to group moderators for admission to group", "#team: member cath (Catherine) is connected", "#team: member dan (Daniel) is connected" @@ -3219,7 +3220,8 @@ testGLinkApproveThenReviewMember = concurrentlyN_ [ alice <## "#team: eve accepted and pending review (will introduce moderators)", eve - <### [ "#team: member cath (Catherine) is connected", + <### [ "#team: alice accepted you to the group, pending review", + "#team: member cath (Catherine) is connected", "#team: member dan (Daniel) is connected" ], do @@ -3309,7 +3311,7 @@ testGLinkApproveThenReviewMember = eve #> "#team 19" [alice, bob, cath, dan] *<# "#team eve> 19" where - cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPending, GRObserver))}} + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} testGLinkDeletePendingApprovalMember :: HasCallStack => TestParams -> IO () testGLinkDeletePendingApprovalMember = @@ -3334,7 +3336,7 @@ testGLinkDeletePendingApprovalMember = cath <## "#team: alice removed you from the group" cath <## "use /d #team to delete the group" where - cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPending, GRObserver))}} + cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}} testPlanGroupLinkKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkKnown = From dd01ef5ae88b535a9bee85a27d91ec8a861fd02e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:45:42 +0000 Subject: [PATCH 13/72] core: create knocking message for moderators (#5857) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 10 +++++++++- .../simplex/common/views/chat/item/ChatItemView.kt | 1 + .../src/commonMain/resources/MR/base/strings.xml | 1 + src/Simplex/Chat/Library/Subscriber.hs | 8 ++++++++ src/Simplex/Chat/Messages/CIContent.hs | 2 ++ src/Simplex/Chat/Messages/CIContent/Events.hs | 1 + 6 files changed, 22 insertions(+), 1 deletion(-) 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 ccc043f93d..bc70933571 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 @@ -2565,7 +2565,12 @@ data class ChatItem ( is CIContent.RcvGroupFeature, is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { - is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null + is RcvGroupEvent.UserRole, + is RcvGroupEvent.UserDeleted, + is RcvGroupEvent.GroupDeleted, + is RcvGroupEvent.MemberCreatedContact, + is RcvGroupEvent.NewMemberPendingReview -> + null else -> CIMergeCategory.RcvGroupEvent } is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { @@ -2653,6 +2658,7 @@ data class ChatItem ( is RcvGroupEvent.InvitedViaGroupLink -> false is RcvGroupEvent.MemberCreatedContact -> false is RcvGroupEvent.MemberProfileUpdated -> false + is RcvGroupEvent.NewMemberPendingReview -> true } is CIContent.SndGroupEventContent -> false is CIContent.RcvConnEventContent -> false @@ -4306,6 +4312,7 @@ sealed class RcvGroupEvent() { @Serializable @SerialName("invitedViaGroupLink") class InvitedViaGroupLink(): RcvGroupEvent() @Serializable @SerialName("memberCreatedContact") class MemberCreatedContact(): RcvGroupEvent() @Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent() + @Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent() val text: String get() = when (this) { is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName) @@ -4327,6 +4334,7 @@ sealed class RcvGroupEvent() { is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link) is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact) is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile) + is NewMemberPendingReview -> generalGetString(MR.strings.rcv_group_event_new_member_pending_review) } private fun profileUpdatedText(from: Profile, to: Profile): String = 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 edd8ddd935..19d470dd8c 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 @@ -721,6 +721,7 @@ fun ChatItemView( is CIContent.RcvGroupEventContent -> { when (c.rcvGroupEvent) { is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + is RcvGroupEvent.NewMemberPendingReview -> UserPendingReviewEventItemView() else -> EventItemView() } MsgContentItemDropdownMenu() 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 d9f789a202..ebc20d0ce9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1587,6 +1587,7 @@ updated group profile invited via your group link connected directly + New member wants to join the group. you changed role of %s to %s you changed role for yourself to %s you blocked %s diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index d44d11fb28..7ae7c13c00 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -790,6 +790,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure $ memberStatus m (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m memberConnectedChatItem gInfo' scopeInfo m' + case scopeInfo of + Just (GCSIMemberSupport _) -> do + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing + _ -> pure () toView $ CRJoinedGroupMember user gInfo' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo' @@ -2467,6 +2471,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent event) groupMsgToView gInfo scopeInfo ci + case scopeInfo of + Just (GCSIMemberSupport _) -> do + createInternalChatItem user (CDGroupRcv gInfo scopeInfo m) (CIRcvGroupEvent RGENewMemberPendingReview) (Just brokerTs) + _ -> pure () toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember' getMemNewChatScope announcedMember = case msgScope_ of Nothing -> pure (announcedMember, Nothing) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 9e93db39b5..9fb3c2e131 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -219,6 +219,7 @@ ciRequiresAttention content = case msgDirection @d of RGEInvitedViaGroupLink -> False RGEMemberCreatedContact -> False RGEMemberProfileUpdated {} -> False + RGENewMemberPendingReview -> True CIRcvConnEvent _ -> True CIRcvChatFeature {} -> False CIRcvChatPreference {} -> False @@ -332,6 +333,7 @@ rcvGroupEventToText = \case RGEInvitedViaGroupLink -> "invited via your group link" RGEMemberCreatedContact -> "started direct connection with you" RGEMemberProfileUpdated {} -> "updated profile" + RGENewMemberPendingReview -> "new member wants to join the group" sndGroupEventToText :: SndGroupEvent -> Text sndGroupEventToText = \case diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 36b3a6ebdd..a49544f6c5 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -31,6 +31,7 @@ data RcvGroupEvent | RGEInvitedViaGroupLink -- CRSentGroupInvitationViaLink | RGEMemberCreatedContact -- CRNewMemberContactReceivedInv | RGEMemberProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRGroupMemberUpdated + | RGENewMemberPendingReview deriving (Show) data SndGroupEvent From 6abffeb29557dd3a4751fc3411cdfc4951b3fb5f Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:03:17 +0400 Subject: [PATCH 14/72] core: only introduce to supporting moderators --- .../chat/simplex/common/views/chat/item/ChatItemView.kt | 6 +++--- src/Simplex/Chat/Library/Internal.hs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 19d470dd8c..f523c8ca30 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 @@ -636,7 +636,7 @@ fun ChatItemView( CIEventView(eventItemViewText(reversedChatItems)) } - @Composable fun UserPendingReviewEventItemView() { + @Composable fun PendingReviewEventItemView() { Text( buildAnnotatedString { withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) } @@ -721,14 +721,14 @@ fun ChatItemView( is CIContent.RcvGroupEventContent -> { when (c.rcvGroupEvent) { is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - is RcvGroupEvent.NewMemberPendingReview -> UserPendingReviewEventItemView() + is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() else -> EventItemView() } MsgContentItemDropdownMenu() } is CIContent.SndGroupEventContent -> { when (c.sndGroupEvent) { - is SndGroupEvent.UserPendingReview -> UserPendingReviewEventItemView() + is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() else -> EventItemView() } MsgContentItemDropdownMenu() diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 46e8177e8e..2c751c4847 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -975,7 +975,7 @@ introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRol else XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing void $ sendDirectMemberMessage mConn msg groupId modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let rcpModMs = filter memberCurrent modMs + let rcpModMs = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () From 32fb5a5d126985cc4174660394cb2a9b74cfaf85 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sun, 4 May 2025 21:27:20 +0000 Subject: [PATCH 15/72] ios: knocking ui (#5851) * ios: knocking ui types * update types * member admission * remove chatItemStatuses * member support view * member support chat view wip * ios: secondary ItemsModel (#5862) * toolbar * more chats * remove theme * preview icon * chat toolbar fast markers * change icon * change icon * remove/accept buttons * item style * get item ItemsModel in chat model methods (chat view doesn't work dynamically) * fix support chat * fix other chats * refresh on exit * refresh button * dynamic marker in chat list * prohibit multi select actions * prohibited send field --- apps/ios/Shared/Model/AppAPITypes.swift | 84 ++- apps/ios/Shared/Model/ChatModel.swift | 279 ++++++--- apps/ios/Shared/Model/SimpleXAPI.swift | 91 +-- apps/ios/Shared/SimpleXApp.swift | 2 +- .../Shared/Views/Chat/ChatInfoToolbar.swift | 27 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 14 +- .../Chat/ChatItem/CIChatFeatureView.swift | 5 +- .../Views/Chat/ChatItem/CIFileView.swift | 21 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 4 +- .../Views/Chat/ChatItem/CIVoiceView.swift | 9 +- .../Chat/ChatItem/FramedCIVoiceView.swift | 11 +- .../Views/Chat/ChatItem/FramedItemView.swift | 64 ++- .../Chat/ChatItem/MarkedDeletedItemView.swift | 8 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 47 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 87 +-- .../Shared/Views/Chat/ChatItemsMerger.swift | 39 +- .../Shared/Views/Chat/ChatScrollHelpers.swift | 33 +- apps/ios/Shared/Views/Chat/ChatView.swift | 543 ++++++++++-------- .../Chat/ComposeMessage/ComposeView.swift | 59 +- .../ContextPendingMemberActionsView.swift | 97 ++++ .../Views/Chat/Group/GroupChatInfoView.swift | 153 ++++- .../Chat/Group/GroupMemberInfoView.swift | 2 +- .../Chat/Group/GroupPreferencesView.swift | 64 +++ .../Chat/Group/MemberAdmissionView.swift | 92 +++ .../Chat/Group/MemberSupportChatToolbar.swift | 44 ++ .../Views/Chat/Group/MemberSupportView.swift | 267 +++++++++ .../Views/Chat/Group/SecondaryChatView.swift | 39 ++ .../Chat/SelectableChatItemToolbars.swift | 22 +- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../Shared/Views/ChatList/ChatListView.swift | 12 +- .../Views/ChatList/ChatPreviewView.swift | 21 +- .../Views/LocalAuth/LocalAuthView.swift | 2 + .../Shared/Views/NewChat/AddGroupView.swift | 2 +- .../UserSettings/AppearanceSettings.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 3 + apps/ios/SimpleX SE/ShareAPI.swift | 23 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 + apps/ios/SimpleXChat/ChatTypes.swift | 210 ++++++- apps/ios/SimpleXChat/ChatUtils.swift | 6 +- apps/ios/SimpleXChat/Notifications.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 1 + .../views/chat/group/MemberSupportView.kt | 2 +- 42 files changed, 1873 insertions(+), 644 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index d7f96284cf..df0ba5c9d7 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -39,9 +39,9 @@ enum ChatCommand: ChatCmdProtocol { case apiGetSettings(settings: AppSettings) case apiGetChatTags(userId: Int64) case apiGetChats(userId: Int64) - case apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String) - case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) - case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) + case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -49,15 +49,15 @@ enum ChatCommand: ChatCmdProtocol { case apiReorderChatTags(tagIds: [Int64]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) - case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) - case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) + case apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) + case apiDeleteChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) case apiArchiveReceivedReports(groupId: Int64) case apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) - case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) + case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) - case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64]) - case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) + case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64]) + case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -68,6 +68,7 @@ enum ChatCommand: ChatCmdProtocol { case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) + case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool) @@ -147,8 +148,8 @@ enum ChatCommand: ChatCmdProtocol { case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) // WebRTC calls / case apiGetNetworkStatuses - case apiChatRead(type: ChatType, id: Int64) - case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) + case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) + case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) @@ -209,15 +210,16 @@ enum ChatCommand: ChatCmdProtocol { case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))" case let .apiGetChatTags(userId): return "/_get tags \(userId)" case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" - case let .apiGetChat(chatId, pagination, search): return "/_get chat \(chatId) \(pagination.cmdString)" + - (search == "" ? "" : " search=\(search)") - case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)" - case let .apiSendMessages(type, id, live, ttl, composedMessages): + case let .apiGetChat(chatId, scope, contentTag, pagination, search): + let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : "" + return "/_get chat \(chatId)\(scopeRef(scope: scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") + case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" + case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" - case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))" case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))" @@ -226,17 +228,17 @@ enum ChatCommand: ChatCmdProtocol { return "/_create *\(noteFolderId) json \(msgs)" case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" - case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)" - case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" + case let .apiUpdateChatItem(type, id, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)" + case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" case let .apiArchiveReceivedReports(groupId): return "/_archive reports #\(groupId)" case let .apiDeleteReceivedReports(groupId, itemIds, mode): return "/_delete reports #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" - case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" + case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" - case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl): + case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl): let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -247,6 +249,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" + case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)" case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))" case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))" @@ -270,13 +273,13 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" - case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))" + case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id, scope: nil)) \(chatItemTTLStr(seconds: seconds))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" case .reconnectAllServers: return "/reconnect" case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" - case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" + case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))" case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" @@ -308,8 +311,8 @@ enum ChatCommand: ChatCmdProtocol { case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")" case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" - case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)" - case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" + case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)" + case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" @@ -334,9 +337,9 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case .apiGetNetworkStatuses: return "/_network_statuses" - case let .apiChatRead(type, id): return "/_read chat \(ref(type, id))" - case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))" - case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" + case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))" + case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))" + case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))" case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))" case let .cancelFile(fileId): return "/fcancel \(fileId)" @@ -421,6 +424,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiNewGroup: return "apiNewGroup" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" + case .apiAcceptMember: return "apiAcceptMember" case .apiMembersRole: return "apiMembersRole" case .apiBlockMembersForAll: return "apiBlockMembersForAll" case .apiRemoveMembers: return "apiRemoveMembers" @@ -523,8 +527,20 @@ enum ChatCommand: ChatCmdProtocol { } } - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" + func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String { + "\(type.rawValue)\(id)\(scopeRef(scope: scope))" + } + + func scopeRef(scope: GroupChatScope?) -> String { + switch (scope) { + case .none: "" + case let .memberSupport(groupMemberId_): + if let groupMemberId = groupMemberId_ { + "(_support:\(groupMemberId))" + } else { + "(_support)" + } + } } func joinedIds(_ ids: [Int64]) -> String { @@ -676,6 +692,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) + case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberAcceptedByOther(user: UserRef, groupInfo: GroupInfo, acceptingMember: GroupMember, member: GroupMember) case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool) @@ -856,6 +874,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .receivedGroupInvitation: return "receivedGroupInvitation" case .groupDeletedUser: return "groupDeletedUser" case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" + case .memberAccepted: return "memberAccepted" + case .memberAcceptedByOther: return "memberAcceptedByOther" case .memberRole: return "memberRole" case .membersRoleUser: return "membersRoleUser" case .memberBlockedForAll: return "memberBlockedForAll" @@ -1043,6 +1063,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") + case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberAcceptedByOther(u, groupInfo, acceptingMember, member): return withUser(u, "groupInfo: \(groupInfo)\nacceptingMember: \(acceptingMember)\nmember: \(member)") case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c73cb32c58..3c27a9875b 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,8 +43,26 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { items.append(item) } +// analogue for SecondaryContextFilter in Kotlin +enum SecondaryItemsModelFilter { + case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) + case msgContentTagContext(contentTag: MsgContentTag) + + func descr() -> String { + switch self { + case let .groupChatScopeContext(groupScopeInfo): + return "groupChatScopeContext \(groupScopeInfo.toChatScope())" + case let .msgContentTagContext(contentTag): + return "msgContentTagContext \(contentTag.rawValue)" + } + } +} + +// analogue for ChatsContext in Kotlin class ItemsModel: ObservableObject { - static let shared = ItemsModel() + static let shared = ItemsModel(secondaryIMFilter: nil) + public var secondaryIMFilter: SecondaryItemsModelFilter? + public var preloadState = PreloadState() private let publisher = ObservableObjectPublisher() private var bag = Set() var reversedChatItems: [ChatItem] = [] { @@ -68,7 +86,8 @@ class ItemsModel: ObservableObject { chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id } - init() { + init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) { + self.secondaryIMFilter = secondaryIMFilter publisher .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) .sink { self.objectWillChange.send() } @@ -83,6 +102,9 @@ class ItemsModel: ObservableObject { try await Task.sleep(nanoseconds: 250_000000) await MainActor.run { ChatModel.shared.chatId = chatId + if secondaryIMFilter != nil { + ChatModel.shared.secondaryIM = self + } willNavigate() } } catch {} @@ -90,7 +112,7 @@ class ItemsModel: ObservableObject { loadChatTask = Task { await MainActor.run { self.isLoading = true } // try? await Task.sleep(nanoseconds: 1000_000000) - await loadChat(chatId: chatId) + await loadChat(chatId: chatId, im: self) if !Task.isCancelled { await MainActor.run { self.isLoading = false @@ -105,7 +127,7 @@ class ItemsModel: ObservableObject { loadChatTask?.cancel() loadChatTask = Task { // try? await Task.sleep(nanoseconds: 1000_000000) - await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) + await loadChat(chatId: chatId, im: self, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) if !Task.isCancelled { await MainActor.run { if openAroundItemId == nil { @@ -115,6 +137,34 @@ class ItemsModel: ObservableObject { } } } + + public var contentTag: MsgContentTag? { + switch secondaryIMFilter { + case nil: nil + case .groupChatScopeContext: nil + case let .msgContentTagContext(contentTag): contentTag + } + } + + public var groupScopeInfo: GroupChatScopeInfo? { + switch secondaryIMFilter { + case nil: nil + case let .groupChatScopeContext(scopeInfo): scopeInfo + case .msgContentTagContext: nil + } + } +} + +class PreloadState { + var prevFirstVisible: Int64 = Int64.min + var prevItemsCount: Int = 0 + var preloading: Bool = false + + func clear() { + prevFirstVisible = Int64.min + prevItemsCount = 0 + preloading = false + } } class ChatTagsModel: ObservableObject { @@ -278,7 +328,6 @@ final class ChatModel: ObservableObject { // current chat @Published var chatId: String? @Published var openAroundItemId: ChatItem.ID? = nil - var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @@ -327,6 +376,9 @@ final class ChatModel: ObservableObject { let im = ItemsModel.shared + // ItemsModel for secondary chat view (such as support scope chat), as opposed to ItemsModel.shared used for primary chat + @Published var secondaryIM: ItemsModel? = nil + static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } let ntfEnableLocal = true @@ -384,7 +436,7 @@ final class ChatModel: ObservableObject { func getGroupChat(_ groupId: Int64) -> Chat? { chats.first { chat in - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { return groupInfo.groupId == groupId } else { return false @@ -459,7 +511,7 @@ final class ChatModel: ObservableObject { } func updateGroup(_ groupInfo: GroupInfo) { - updateChat(.group(groupInfo: groupInfo)) + updateChat(.group(groupInfo: groupInfo, groupChatScope: nil)) } private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { @@ -502,77 +554,105 @@ final class ChatModel: ObservableObject { // } func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { - // mark chat non deleted - if case let .direct(contact) = cInfo, contact.chatDeleted { - var updatedContact = contact - updatedContact.chatDeleted = false - updateContact(updatedContact) - } - // update previews - if let i = getChatIndex(cInfo.id) { - chats[i].chatItems = switch cInfo { - case .group: - if let currentPreviewItem = chats[i].chatItems.first { - if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { - [cItem] + // update chat list + if cInfo.groupChatScope() == nil { + // mark chat non deleted + if case let .direct(contact) = cInfo, contact.chatDeleted { + var updatedContact = contact + updatedContact.chatDeleted = false + updateContact(updatedContact) + } + // update preview + if let i = getChatIndex(cInfo.id) { + chats[i].chatItems = switch cInfo { + case .group: + if let currentPreviewItem = chats[i].chatItems.first { + if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { + [cItem] + } else { + [currentPreviewItem] + } } else { - [currentPreviewItem] + [cItem] } - } else { + default: [cItem] } - default: - [cItem] + if case .rcvNew = cItem.meta.itemStatus { + unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) + } + popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) + } else { + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) } - if case .rcvNew = cItem.meta.itemStatus { - unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) - } - popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) - } else { - addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) } - // add to current chat - if chatId == cInfo.id { - _ = _upsertChatItem(cInfo, cItem) + // add to current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + _ = _upsertChatItem(ciIM, cInfo, cItem) + } + } + + func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? { + let cInfoScope = cInfo.groupChatScope() + if let cInfoScope = cInfoScope { + switch cInfoScope { + case .memberSupport: + switch secondaryIM?.secondaryIMFilter { + case .none: + return nil + case let .groupChatScopeContext(groupScopeInfo): + return (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil + case let .msgContentTagContext(contentTag): + return (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil + } + } + } else { + return cInfo.id == chatId ? im : nil } } func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { - // update previews - var res: Bool - if let chat = getChat(cInfo.id) { - if let pItem = chat.chatItems.last { - if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + // update chat list + var itemAdded: Bool = false + if cInfo.groupChatScope() == nil { + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last { + if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + chat.chatItems = [cItem] + } + } else { chat.chatItems = [cItem] } } else { - chat.chatItems = [cItem] + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + itemAdded = true + } + if cItem.isDeletedContent || cItem.meta.itemDeleted != nil { + VoiceItemState.stopVoiceInChatView(cInfo, cItem) } - res = false - } else { - addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) - res = true } - if cItem.isDeletedContent || cItem.meta.itemDeleted != nil { - VoiceItemState.stopVoiceInChatView(cInfo, cItem) + // update current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + itemAdded = _upsertChatItem(ciIM, cInfo, cItem) } - // update current chat - return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res + return itemAdded } - private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { - if let i = getChatItemIndex(cItem) { - _updateChatItem(at: i, with: cItem) - ChatItemDummyModel.shared.sendUpdate() + private func _upsertChatItem(_ ciIM: ItemsModel, _ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { + if let i = getChatItemIndex(ciIM, cItem) { + let oldStatus = ciIM.reversedChatItems[i].meta.itemStatus + let newStatus = cItem.meta.itemStatus + var ci = cItem + if shouldKeepOldSndCIStatus(oldStatus: oldStatus, newStatus: newStatus) { + ci.meta.itemStatus = oldStatus + } + _updateChatItem(ciIM: ciIM, at: i, with: ci) + ChatItemDummyModel.shared.sendUpdate() // TODO [knocking] review what's this return false } else { - var ci = cItem - if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { - ci.meta.itemStatus = status - } - im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) - im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) - im.itemAdded = true + ciIM.reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + ciIM.chatState.itemAdded((cItem.id, cItem.isRcvNew), hasLiveDummy ? 1 : 0) + ciIM.itemAdded = true ChatItemDummyModel.shared.sendUpdate() return true } @@ -586,40 +666,42 @@ final class ChatModel: ObservableObject { } func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { - if chatId == cInfo.id, let i = getChatItemIndex(cItem) { + if let ciIM = getCIItemsModel(cInfo, cItem), + let i = getChatItemIndex(ciIM, cItem) { withConditionalAnimation { - _updateChatItem(at: i, with: cItem) + _updateChatItem(ciIM: ciIM, at: i, with: cItem) } - } else if let status = status { - chatItemStatuses.updateValue(status, forKey: cItem.id) } } - private func _updateChatItem(at i: Int, with cItem: ChatItem) { - im.reversedChatItems[i] = cItem - im.reversedChatItems[i].viewTimestamp = .now + private func _updateChatItem(ciIM: ItemsModel, at i: Int, with cItem: ChatItem) { + ciIM.reversedChatItems[i] = cItem + ciIM.reversedChatItems[i].viewTimestamp = .now } - func getChatItemIndex(_ cItem: ChatItem) -> Int? { - im.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) + func getChatItemIndex(_ ciIM: ItemsModel, _ cItem: ChatItem) -> Int? { + ciIM.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) } func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { - if cItem.isRcvNew { - unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) - } - // update previews - if let chat = getChat(cInfo.id) { - if let pItem = chat.chatItems.last, pItem.id == cItem.id { - chat.chatItems = [ChatItem.deletedItemDummy()] + // update chat list + if cInfo.groupChatScope() == nil { + if cItem.isRcvNew { + unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) + } + // update previews + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last, pItem.id == cItem.id { + chat.chatItems = [ChatItem.deletedItemDummy()] + } } } - // remove from current chat - if chatId == cInfo.id { - if let i = getChatItemIndex(cItem) { + // remove from current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + if let i = getChatItemIndex(ciIM, cItem) { withAnimation { - let item = im.reversedChatItems.remove(at: i) - im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) + let item = ciIM.reversedChatItems.remove(at: i) + ciIM.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) } } } @@ -635,7 +717,7 @@ final class ChatModel: ObservableObject { if chatId == groupInfo.id { for i in 0.. (Int?, ChatItem?) { - if let i = getChatItemIndex(ci) { + if let i = getChatItemIndex(im, ci) { // TODO [knocking] review: use getCIItemsModel? (i, i > 0 ? im.reversedChatItems[i - 1] : nil) } else { (nil, nil) @@ -1091,7 +1189,7 @@ final class ChatModel: ObservableObject { func removeWallpaperFilesFromChat(_ chat: Chat) { if case let .direct(contact) = chat.chatInfo { removeWallpaperFilesFromTheme(contact.uiThemes) - } else if case let .group(groupInfo) = chat.chatInfo { + } else if case let .group(groupInfo, _) = chat.chatInfo { removeWallpaperFilesFromTheme(groupInfo.uiThemes) } } @@ -1146,9 +1244,9 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var userCanSend: Bool { switch chatInfo { case .direct: return true - case let .group(groupInfo): + case let .group(groupInfo, groupChatScope): let m = groupInfo.membership - return m.memberActive && m.memberRole >= .member + return (m.memberActive && m.memberRole >= .member && !m.memberPending) || groupChatScope != nil case .local: return true default: return false @@ -1157,13 +1255,20 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var userIsObserver: Bool { switch chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): let m = groupInfo.membership return m.memberActive && m.memberRole == .observer default: return false } } + var userIsPending: Bool { + switch chatInfo { + case let .group(groupInfo, _): groupInfo.membership.memberPending + default: false + } + } + var unreadTag: Bool { switch chatInfo.chatSettings?.enableNtfs { case .all: chatStats.unreadChat || chatStats.unreadCount > 0 diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4e9c8ce7b6..65f66fc0c0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -328,43 +328,54 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] { let loadItemsPerPage = 50 -func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { - let r = await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search)) +func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { + let r = await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search)) if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) } throw r } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { - await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) +func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async { + await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems) } -func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { - let m = ChatModel.shared - let im = ItemsModel.shared +func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { await MainActor.run { - m.chatItemStatuses = [:] if clearItems { im.reversedChatItems = [] - ItemsModel.shared.chatState.clear() + im.chatState.clear() } } - await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 }) + await apiLoadMessages( + chatId, + im, + ( // pagination + openAroundItemId != nil + ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) + : ( + search == "" + ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage) + ) + ), + search, + openAroundItemId, + { 0...0 } + ) } -func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { - let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId)) +func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo { + let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId)) if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo } throw r } -func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { - let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds)) +func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { + let r = await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds)) if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) } throw r } -func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { - let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl) +func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { + let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } @@ -396,8 +407,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws { try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) } -func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } @@ -474,14 +485,14 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) { ) } -func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem { - let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay) +func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem { + let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay) if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } throw r } -func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem { - let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay) +func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem { + let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay) if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem } throw r } @@ -493,8 +504,8 @@ func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) throw r } -func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { - let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay) +func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { + let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay) if case let .chatItemsDeleted(_, items, _) = r { return items } throw r } @@ -1212,12 +1223,12 @@ func apiRejectContactRequest(contactReqId: Int64) async throws { throw r } -func apiChatRead(type: ChatType, id: Int64) async throws { - try await sendCommandOkResp(.apiChatRead(type: type, id: id)) +func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws { + try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) } -func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws { - try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds)) +func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws { + try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds)) } func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { @@ -1523,7 +1534,7 @@ func markChatRead(_ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo - try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) + try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope()) await MainActor.run { withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) } } @@ -1550,7 +1561,7 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { - try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds) + try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead) } @@ -1600,6 +1611,12 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } +func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { + let r = await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole)) + if case let .memberAccepted(_, _, member) = r { return member } + throw r +} + func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> [GroupMember] { let r = await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) if case let .userDeletedMembers(_, _, members, withMessages) = r { return members } @@ -2086,6 +2103,9 @@ func processReceivedMsg(_ res: ChatResponse) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } + if cInfo.groupChatScope() != nil && cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { + m.increaseGroupSupportChatsUnreadCounter(cInfo.id) + } } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } @@ -2104,7 +2124,7 @@ func processReceivedMsg(_ res: ChatResponse) async { let cInfo = chatItem.chatInfo let cItem = chatItem.chatItem if !cItem.isDeletedContent && active(user) { - await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) } + _ = await MainActor.run { m.upsertChatItem(cInfo, cItem) } } if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { @@ -2200,6 +2220,12 @@ func processReceivedMsg(_ res: ChatResponse) async { _ = m.upsertGroupMember(groupInfo, member) } } + case let .memberAcceptedByOther(user, groupInfo, _, member): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, member) + } + } case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member if active(user) { await MainActor.run { @@ -2231,6 +2257,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } } case let .userJoinedGroup(user, groupInfo): + // TODO [knocking] close support scope for this group if it's currently opened if active(user) { await MainActor.run { m.updateGroup(groupInfo) @@ -2517,7 +2544,7 @@ func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemID return } let im = ItemsModel.shared - let cInfo = ChatInfo.group(groupInfo: groupInfo) + let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil) await MainActor.run { m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index f8d69c5fc8..47c0f61c79 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -159,7 +159,7 @@ struct SimpleXApp: App { if let id = chatModel.chatId, let chat = chatModel.getChat(id), !NtfManager.shared.navigatingToChat { - Task { await loadChat(chat: chat, clearItems: false) } + Task { await loadChat(chat: chat, im: ItemsModel.shared, clearItems: false) } } if let ncr = chatModel.ntfContactRequest { await MainActor.run { chatModel.ntfContactRequest = nil } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 62a41c504a..9a2032ea4c 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -22,11 +22,28 @@ struct ChatInfoToolbar: View { Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo) Spacer().frame(width: 16) } - ChatInfoImage( - chat: chat, - size: imageSize, - color: Color(uiColor: .tertiaryLabel) - ) + ZStack(alignment: .bottomTrailing) { + ChatInfoImage( + chat: chat, + size: imageSize, + color: Color(uiColor: .tertiaryLabel) + ) + if chat.chatStats.reportsCount > 0 { + Image(systemName: "flag.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .red) + } else if chat.chatStats.supportChatsUnreadCount > 0 { + Image(systemName: "flag.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, theme.colors.primary) + } + } .padding(.trailing, 4) let t = Text(cInfo.displayName).font(.headline) (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 8194c8fe6f..0498dc5d70 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -687,7 +687,7 @@ struct ChatTTLOption: View { let m = ChatModel.shared do { try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl) - await loadChat(chat: chat, clearItems: true) + await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true) await MainActor.run { progressIndicator = false currentChatItemTTL = chatItemTTL @@ -700,7 +700,7 @@ struct ChatTTLOption: View { } catch let error { logger.error("setChatTTL error \(responseError(error))") - await loadChat(chat: chat, clearItems: true) + await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true) await MainActor.run { chatItemTTL = currentChatItemTTL progressIndicator = false @@ -938,7 +938,7 @@ struct ChatWallpaperEditorSheet: View { self.chat = chat self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes { uiThemes - } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { + } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { uiThemes } else { ThemeModeOverrides() @@ -974,7 +974,7 @@ struct ChatWallpaperEditorSheet: View { private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides { if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes { uiThemes - } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { + } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { uiThemes } else { ThemeModeOverrides() @@ -1052,12 +1052,12 @@ struct ChatWallpaperEditorSheet: View { chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact)) themes = themesFromChat(chat.wrappedValue) } - } else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo { + } else if case var ChatInfo.group(groupInfo, _) = chat.wrappedValue.chatInfo { groupInfo.uiThemes = changedThemesConstant await MainActor.run { - ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo)) - chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo)) + ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)) + chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)) themes = themesFromChat(chat.wrappedValue) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index 02be8af73b..b2b4441646 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -12,8 +12,8 @@ import SimpleXChat struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel @Environment(\.revealed) var revealed: Bool - @ObservedObject var im = ItemsModel.shared @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @EnvironmentObject var theme: AppTheme var chatItem: ChatItem var feature: Feature @@ -53,7 +53,7 @@ struct CIChatFeatureView: View { private func mergedFeatures() -> [FeatureInfo]? { var fs: [FeatureInfo] = [] var icons: Set = [] - if var i = m.getChatItemIndex(chatItem) { + if var i = m.getChatItemIndex(im, chatItem) { while i < im.reversedChatItems.count, let f = featureInfo(im.reversedChatItems[i]) { if !icons.contains(f.icon) { @@ -108,6 +108,7 @@ struct CIChatFeatureView_Previews: PreviewProvider { let enabled = FeatureEnabled(forUser: false, forContact: false) CIChatFeatureView( chat: Chat.sampleData, + im: ItemsModel.shared, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary) ).environment(\.revealed, true) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index bc44afdd7f..18daae4b4d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -278,6 +278,7 @@ func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { struct CIFileView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentFile: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -293,16 +294,16 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItemId: { _ in }) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 6920ab999b..1534872003 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -45,7 +45,7 @@ struct CIRcvDecryptionError: View { viewBody() .onAppear { // for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear - if case let .group(groupInfo) = chat.chatInfo, + if case let .group(groupInfo, _) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir { do { let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId) @@ -83,7 +83,7 @@ struct CIRcvDecryptionError: View { } else { basicDecryptionErrorItem() } - } else if case let .group(groupInfo) = chat.chatInfo, + } else if case let .group(groupInfo, _) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir, let mem = m.getGroupMember(groupMember.groupMemberId), let memberStats = mem.wrapped.activeConn?.connectionStats { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index e2fec02ba1..30adf253b7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -476,6 +476,7 @@ class VoiceItemState { struct CIVoiceView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -498,10 +499,10 @@ struct CIVoiceView_Previews: PreviewProvider { duration: 30, allowMenu: Binding.constant(true) ) - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true)) } .previewLayout(.fixed(width: 360, height: 360)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index f4e2a4135a..2378666990 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -77,6 +77,7 @@ struct FramedCIVoiceView: View { struct FramedCIVoiceView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -92,11 +93,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in }) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index e411befbfa..3dfe961f72 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -13,6 +13,7 @@ struct FramedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var scrollToItemId: (ChatItem.ID) -> Void var preview: UIImage? @@ -72,7 +73,7 @@ struct FramedItemView: View { framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) } - ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: framedMsgContentView) + ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView) .padding(chatItem.content.msgContent != nil ? 0 : 4) .overlay(DetermineWidth()) } @@ -292,7 +293,7 @@ struct FramedItemView: View { private func membership() -> GroupMember? { switch chat.chatInfo { - case let .group(groupInfo: groupInfo): return groupInfo.membership + case let .group(groupInfo: groupInfo, _): return groupInfo.membership default: return nil } } @@ -395,15 +396,16 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color { struct FramedItemView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group{ - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -411,17 +413,18 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) @@ -430,17 +433,18 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 87a9b2ce61..c6a5d0353c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -14,6 +14,7 @@ struct MarkedDeletedItemView: View { @EnvironmentObject var theme: AppTheme @Environment(\.revealed) var revealed: Bool @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var body: some View { @@ -29,14 +30,14 @@ struct MarkedDeletedItemView: View { var mergedMarkedDeletedText: LocalizedStringKey { if !revealed, let ciCategory = chatItem.mergeCategory, - var i = m.getChatItemIndex(chatItem) { + var i = m.getChatItemIndex(im, chatItem) { var moderated = 0 var blocked = 0 var blockedByAdmin = 0 var deleted = 0 var moderatedBy: Set = [] - while i < ItemsModel.shared.reversedChatItems.count, - let ci = .some(ItemsModel.shared.reversedChatItems[i]), + while i < im.reversedChatItems.count, + let ci = .some(im.reversedChatItems[i]), ci.mergeCategory == ciCategory, let itemDeleted = ci.meta.itemDeleted { switch itemDeleted { @@ -85,6 +86,7 @@ struct MarkedDeletedItemView_Previews: PreviewProvider { Group { MarkedDeletedItemView( chat: Chat.sampleData, + im: ItemsModel.shared, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)) ).environment(\.revealed, true) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5d09e153d5..92e364a846 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -31,6 +31,7 @@ extension EnvironmentValues { struct ChatItemView: View { @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @EnvironmentObject var theme: AppTheme @Environment(\.showTimestamp) var showTimestamp: Bool @Environment(\.revealed) var revealed: Bool @@ -41,6 +42,7 @@ struct ChatItemView: View { init( chat: Chat, + im: ItemsModel, chatItem: ChatItem, scrollToItemId: @escaping (ChatItem.ID) -> Void, showMember: Bool = false, @@ -48,6 +50,7 @@ struct ChatItemView: View { allowMenu: Binding = .constant(false) ) { self.chat = chat + self.im = im self.chatItem = chatItem self.scrollToItemId = scrollToItemId self.maxWidth = maxWidth @@ -57,14 +60,14 @@ struct ChatItemView: View { var body: some View { let ci = chatItem if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { - MarkedDeletedItemView(chat: chat, chatItem: chatItem) + MarkedDeletedItemView(chat: chat, im: im, chatItem: chatItem) } else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { EmojiItemView(chat: chat, chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu) } else if ci.content.msgContent == nil { - ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case + ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case } else { framedItemView() } @@ -92,6 +95,7 @@ struct ChatItemView: View { }() return FramedItemView( chat: chat, + im: im, chatItem: chatItem, scrollToItemId: scrollToItemId, preview: preview, @@ -108,6 +112,7 @@ struct ChatItemContentView: View { @EnvironmentObject var theme: AppTheme @Environment(\.revealed) var revealed: Bool @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var msgContentView: () -> Content @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -131,7 +136,9 @@ struct ChatItemContentView: View { case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case .rcvDirectEvent: eventItemView() case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) + case .rcvGroupEvent(.newMemberPendingReview): CIEventView(eventText: pendingReviewEventItemText()) case .rcvGroupEvent: eventItemView() + case .sndGroupEvent(.userPendingReview): CIEventView(eventText: pendingReviewEventItemText()) case .sndGroupEvent: eventItemView() case .rcvConnEvent: eventItemView() case .sndConnEvent: eventItemView() @@ -140,7 +147,7 @@ struct ChatItemContentView: View { case let .rcvChatPreference(feature, allowed, param): CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) + CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) @@ -172,6 +179,13 @@ struct ChatItemContentView: View { CIEventView(eventText: eventItemViewText(theme.colors.secondary)) } + private func pendingReviewEventItemText() -> Text { + Text(chatItem.content.text) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .fontWeight(.bold) + } + private func eventItemViewText(_ secondaryColor: Color) -> Text { if !revealed, let t = mergedGroupEventText { return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor) @@ -187,7 +201,7 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor) + CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, iconColor: iconColor) } private var mergedGroupEventText: Text? { @@ -247,16 +261,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group{ - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 70)) @@ -266,10 +281,12 @@ struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -281,6 +298,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), @@ -292,6 +310,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -303,6 +322,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -314,6 +334,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 07034cf8ec..93ecf870eb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -13,8 +13,8 @@ let TRIM_KEEP_COUNT = 200 func apiLoadMessages( _ chatId: ChatId, + _ im: ItemsModel, _ pagination: ChatPagination, - _ chatState: ActiveChatState, _ search: String = "", _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } @@ -22,7 +22,7 @@ func apiLoadMessages( let chat: Chat let navInfo: NavigationInfo do { - (chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search) + (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search) } catch let error { logger.error("apiLoadMessages error: \(responseError(error))") return @@ -38,30 +38,31 @@ func apiLoadMessages( return } - let unreadAfterItemId = chatState.unreadAfterItemId + let unreadAfterItemId = im.chatState.unreadAfterItemId - let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed()) + let oldItems = Array(im.reversedChatItems.reversed()) var newItems: [ChatItem] = [] switch pagination { case .initial: let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] } - if chatModel.getChat(chat.id) == nil { + if im.secondaryIMFilter == nil && chatModel.getChat(chat.id) == nil { chatModel.addChat(chat) } await MainActor.run { - chatModel.chatItemStatuses.removeAll() - ItemsModel.shared.reversedChatItems = chat.chatItems.reversed() - chatModel.updateChatInfo(chat.chatInfo) - chatState.splits = newSplits - if !chat.chatItems.isEmpty { - chatState.unreadAfterItemId = chat.chatItems.last!.id + im.reversedChatItems = chat.chatItems.reversed() + if im.secondaryIMFilter == nil { + chatModel.updateChatInfo(chat.chatInfo) } - chatState.totalAfter = navInfo.afterTotal - chatState.unreadTotal = chat.chatStats.unreadCount - chatState.unreadAfter = navInfo.afterUnread - chatState.unreadAfterNewestLoaded = navInfo.afterUnread + im.chatState.splits = newSplits + if !chat.chatItems.isEmpty { + im.chatState.unreadAfterItemId = chat.chatItems.last!.id + } + im.chatState.totalAfter = navInfo.afterTotal + im.chatState.unreadTotal = chat.chatStats.unreadCount + im.chatState.unreadAfter = navInfo.afterUnread + im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread - PreloadState.shared.clear() + im.preloadState.clear() } case let .before(paginationChatItemId, _): newItems.append(contentsOf: oldItems) @@ -71,15 +72,15 @@ func apiLoadMessages( let wasSize = newItems.count let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() } let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes + unreadAfterItemId, &newItems, newIds, im.chatState.splits, visibleItemIndexes ) let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0) newItems.insert(contentsOf: chat.chatItems, at: insertAt) let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { - ItemsModel.shared.reversedChatItems = newReversed - chatState.splits = modifiedSplits.newSplits - chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) + im.reversedChatItems = newReversed + im.chatState.splits = modifiedSplits.newSplits + im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) } case let .after(paginationChatItemId, _): newItems.append(contentsOf: oldItems) @@ -89,7 +90,7 @@ func apiLoadMessages( let mappedItems = mapItemsToIds(chat.chatItems) let newIds = mappedItems.0 let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination( - mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits + mappedItems.1, paginationChatItemId, &newItems, newIds, chat, im.chatState.splits ) let indexToAdd = min(indexInCurrentItems + 1, newItems.count) let indexToAddIsLast = indexToAdd == newItems.count @@ -97,19 +98,19 @@ func apiLoadMessages( let new: [ChatItem] = newItems let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { - ItemsModel.shared.reversedChatItems = newReversed - chatState.splits = newSplits - chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new) + im.reversedChatItems = newReversed + im.chatState.splits = newSplits + im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new) // loading clear bottom area, updating number of unread items after the newest loaded item if indexToAddIsLast { - chatState.unreadAfterNewestLoaded -= unreadInLoaded + im.chatState.unreadAfterNewestLoaded -= unreadInLoaded } } case .around: var newSplits: [Int64] if openAroundItemId == nil { newItems.append(contentsOf: oldItems) - newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, im.chatState.splits, visibleItemIndexesNonReversed) } else { newSplits = [] } @@ -120,33 +121,37 @@ func apiLoadMessages( let newReversed: [ChatItem] = newItems.reversed() let orderedSplits = newSplits await MainActor.run { - ItemsModel.shared.reversedChatItems = newReversed - chatState.splits = orderedSplits - chatState.unreadAfterItemId = chat.chatItems.last!.id - chatState.totalAfter = navInfo.afterTotal - chatState.unreadTotal = chat.chatStats.unreadCount - chatState.unreadAfter = navInfo.afterUnread + im.reversedChatItems = newReversed + im.chatState.splits = orderedSplits + im.chatState.unreadAfterItemId = chat.chatItems.last!.id + im.chatState.totalAfter = navInfo.afterTotal + im.chatState.unreadTotal = chat.chatStats.unreadCount + im.chatState.unreadAfter = navInfo.afterUnread if let openAroundItemId { - chatState.unreadAfterNewestLoaded = navInfo.afterUnread - ChatModel.shared.openAroundItemId = openAroundItemId - ChatModel.shared.chatId = chatId + im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread + if im.secondaryIMFilter == nil { + ChatModel.shared.openAroundItemId = openAroundItemId // TODO [knocking] move openAroundItemId from ChatModel to ItemsModel? + ChatModel.shared.chatId = chat.id + } } else { // no need to set it, count will be wrong // chatState.unreadAfterNewestLoaded = navInfo.afterUnread } - PreloadState.shared.clear() + im.preloadState.clear() } case .last: newItems.append(contentsOf: oldItems) - let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits) + let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, im.chatState.splits) newItems.append(contentsOf: chat.chatItems) let items = newItems await MainActor.run { - ItemsModel.shared.reversedChatItems = items.reversed() - chatState.splits = newSplits - chatModel.updateChatInfo(chat.chatInfo) - chatState.unreadAfterNewestLoaded = 0 + im.reversedChatItems = items.reversed() + im.chatState.splits = newSplits + if im.secondaryIMFilter == nil { + chatModel.updateChatInfo(chat.chatInfo) + } + im.chatState.unreadAfterNewestLoaded = 0 } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 0a55ed48cc..5f2102b8bc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct MergedItems: Hashable, Equatable { + let im: ItemsModel let items: [MergedItem] let splits: [SplitRange] // chat item id, index in list @@ -23,15 +24,15 @@ struct MergedItems: Hashable, Equatable { hasher.combine("\(items.hashValue)") } - static func create(_ items: [ChatItem], _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems { - if items.isEmpty { - return MergedItems(items: [], splits: [], indexInParentItems: [:]) + static func create(_ im: ItemsModel, _ revealedItems: Set) -> MergedItems { + if im.reversedChatItems.isEmpty { + return MergedItems(im: im, items: [], splits: [], indexInParentItems: [:]) } - let unreadCount = chatState.unreadTotal + let unreadCount = im.chatState.unreadTotal - let unreadAfterItemId = chatState.unreadAfterItemId - let itemSplits = chatState.splits + let unreadAfterItemId = im.chatState.unreadAfterItemId + let itemSplits = im.chatState.splits var mergedItems: [MergedItem] = [] // Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems var splitRanges: [SplitRange] = [] @@ -40,19 +41,19 @@ struct MergedItems: Hashable, Equatable { var unclosedSplitIndex: Int? = nil var unclosedSplitIndexInParent: Int? = nil var visibleItemIndexInParent = -1 - var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded + var unreadBefore = unreadCount - im.chatState.unreadAfterNewestLoaded var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil var lastRangeInReversedForMergedItems: BoxedValue>? = nil var recent: MergedItem? = nil - while index < items.count { - let item = items[index] - let prev = index >= 1 ? items[index - 1] : nil - let next = index + 1 < items.count ? items[index + 1] : nil + while index < im.reversedChatItems.count { + let item = im.reversedChatItems[index] + let prev = index >= 1 ? im.reversedChatItems[index - 1] : nil + let next = index + 1 < im.reversedChatItems.count ? im.reversedChatItems[index + 1] : nil let category = item.mergeCategory let itemIsSplit = itemSplits.contains(item.id) if item.id == unreadAfterItemId { - unreadBefore = unreadCount - chatState.unreadAfter + unreadBefore = unreadCount - im.chatState.unreadAfter } if item.isRcvNew { unreadBefore -= 1 @@ -106,18 +107,19 @@ struct MergedItems: Hashable, Equatable { // found item that is considered as a split if let unclosedSplitIndex, let unclosedSplitIndexInParent { // it was at least second split in the list - splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1)) + splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1)) } unclosedSplitIndex = index unclosedSplitIndexInParent = visibleItemIndexInParent - } else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent { + } else if index + 1 == im.reversedChatItems.count, let unclosedSplitIndex, let unclosedSplitIndexInParent { // just one split for the whole list, there will be no more, it's the end - splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent)) + splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent)) } indexInParentItems[item.id] = visibleItemIndexInParent index += 1 } return MergedItems( + im: im, items: mergedItems, splits: splitRanges, indexInParentItems: indexInParentItems @@ -127,7 +129,6 @@ struct MergedItems: Hashable, Equatable { // Use this check to ensure that mergedItems state based on currently actual state of global // splits and reversedChatItems func isActualState() -> Bool { - let im = ItemsModel.shared // do not load anything if global splits state is different than in merged items because it // will produce undefined results in terms of loading and placement of items. // Same applies to reversedChatItems @@ -434,7 +435,7 @@ class BoxedValue: Equatable, Hashable { } @MainActor -func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange { +func visibleItemIndexesNonReversed(_ im: ItemsModel, _ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange { let zero = 0 ... 0 let items = mergedItems.items if items.isEmpty { @@ -445,12 +446,12 @@ func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.Li guard let newest, let oldest else { return zero } - let size = ItemsModel.shared.reversedChatItems.count + let size = im.reversedChatItems.count let range = size - oldest ... size - newest if range.lowerBound < 0 || range.upperBound < 0 { return zero } - // visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed() + // visible items mapped to their underlying data structure which is im.reversedChatItems.reversed() return range } diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index c1a1eec7d2..2fb1c3fb35 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -9,7 +9,7 @@ import SwiftUI import SimpleXChat -func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat) async { +func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat, _ im: ItemsModel) async { await MainActor.run { loadingMoreItems.wrappedValue = true loadingBottomItems.wrappedValue = true @@ -22,27 +22,15 @@ func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Bindin } return } - await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState) + await apiLoadMessages(chat.chatInfo.id, im, ChatPagination.last(count: 50)) await MainActor.run { loadingMoreItems.wrappedValue = false loadingBottomItems.wrappedValue = false } } -class PreloadState { - static let shared = PreloadState() - var prevFirstVisible: Int64 = Int64.min - var prevItemsCount: Int = 0 - var preloading: Bool = false - - func clear() { - prevFirstVisible = Int64.min - prevItemsCount = 0 - preloading = false - } -} - func preloadIfNeeded( + _ im: ItemsModel, _ allowLoadMoreItems: Binding, _ ignoreLoadingRequests: Binding, _ listState: EndlessScrollView.ListState, @@ -50,7 +38,7 @@ func preloadIfNeeded( loadItems: @escaping (Bool, ChatPagination) async -> Bool, loadLastItems: @escaping () async -> Void ) { - let state = PreloadState.shared + let state = im.preloadState guard !listState.isScrolling && !listState.isAnimatedScrolling, !state.preloading, listState.totalItemsCount > 0 @@ -63,7 +51,7 @@ func preloadIfNeeded( Task { defer { state.preloading = false } var triedToLoad = true - await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in + await preloadItems(im, mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in triedToLoad = await loadItems(false, pagination) return triedToLoad } @@ -73,11 +61,11 @@ func preloadIfNeeded( } // it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one // split will be merged with last items and position of scroll will change unexpectedly. - if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded { + if listState.itemsCanCoverScreen && !im.lastItemsLoaded { await loadLastItems() } } - } else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded { + } else if listState.itemsCanCoverScreen && !im.lastItemsLoaded { state.preloading = true Task { defer { state.preloading = false } @@ -87,6 +75,7 @@ func preloadIfNeeded( } func preloadItems( + _ im: ItemsModel, _ mergedItems: MergedItems, _ allowLoadMoreItems: Bool, _ listState: EndlessScrollView.ListState, @@ -105,7 +94,7 @@ async { let splits = mergedItems.splits let lastVisibleIndex = listState.lastVisibleItemIndex var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed() + let items: [ChatItem] = im.reversedChatItems.reversed() if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining { lastIndexToLoadFrom = items.count - 1 } @@ -122,7 +111,7 @@ async { let sizeWas = items.count let firstItemIdWas = items.first?.id let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT)) - if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id { + if triedToLoad && sizeWas == im.reversedChatItems.count && firstItemIdWas == im.reversedChatItems.last?.id { ignoreLoadingRequests.wrappedValue = loadFromItemId return false } @@ -133,7 +122,7 @@ async { let splits = mergedItems.splits let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) }) // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) - let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems + let reversedItems: [ChatItem] = im.reversedChatItems if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex { let index = split.indexRangeInReversed.lowerBound if index >= 0 { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1349996683..11a54f6fb7 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -15,8 +15,6 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel - @ObservedObject var im = ItemsModel.shared - @State var mergedItems: BoxedValue = BoxedValue(MergedItems.create(ItemsModel.shared.reversedChatItems, [], ItemsModel.shared.chatState)) @State var revealedItems: Set = Set() @State var theme: AppTheme = buildTheme() @Environment(\.dismiss) var dismiss @@ -24,6 +22,9 @@ struct ChatView: View { @Environment(\.presentationMode) var presentationMode @Environment(\.scenePhase) var scenePhase @State @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel + @State var mergedItems: BoxedValue + @State var floatingButtonModel: FloatingButtonModel @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -55,7 +56,6 @@ struct ChatView: View { @State private var allowLoadMoreItems: Bool = false @State private var ignoreLoadingRequests: Int64? = nil @State private var animatedScrollingInProgress: Bool = false - @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @@ -80,7 +80,7 @@ struct ChatView: View { let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background) let tintColor = theme.wallpaper.tint ?? wallpaperType.defaultTintColor(theme.base) Color.clear.ignoresSafeArea(.all) - .if(wallpaperImage != nil) { view in + .if(wallpaperImage != nil && im.secondaryIMFilter == nil) { view in view.modifier( ChatViewBackground(image: wallpaperImage!, imageType: wallpaperType, background: backgroundColor, tint: tintColor) ) @@ -91,8 +91,8 @@ struct ChatView: View { if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) } - FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: { - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + FloatingButtons(im: im, theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: { + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) } ) @@ -101,6 +101,7 @@ struct ChatView: View { if selectedChatItems == nil { ComposeView( chat: chat, + im: im, composeState: $composeState, keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, @@ -109,7 +110,7 @@ struct ChatView: View { .disabled(!cInfo.sendMsgEnabled) } else { SelectedItemsBottomToolbar( - chatItems: ItemsModel.shared.reversedChatItems, + im: im, selectedChatItems: $selectedChatItems, chatInfo: chat.chatInfo, deleteItems: { forAll in @@ -120,7 +121,7 @@ struct ChatView: View { showArchiveSelectedReports = true }, moderateItems: { - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { showModerateSelectedMessagesAlert(groupInfo) } }, @@ -163,7 +164,7 @@ struct ChatView: View { archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages) } } - if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive { + if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let selected = selectedChatItems { archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages) @@ -173,7 +174,7 @@ struct ChatView: View { } .appSheet(item: $selectedMember) { member in Group { - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { GroupMemberInfoView( groupInfo: groupInfo, chat: chat, @@ -186,7 +187,7 @@ struct ChatView: View { // it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field .appSheet(isPresented: $showAddMembersSheet) { Group { - if case let .group(groupInfo) = cInfo { + if case let .group(groupInfo, _) = cInfo { AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } @@ -236,7 +237,7 @@ struct ChatView: View { initChatView() theme = buildTheme() closeSearch() - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { @@ -256,7 +257,7 @@ struct ChatView: View { .onChange(of: chatModel.openAroundItemId) { openAround in if let openAround { closeSearch() - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) chatModel.openAroundItemId = nil @@ -279,9 +280,8 @@ struct ChatView: View { if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { - chatModel.chatItemStatuses = [:] - ItemsModel.shared.reversedChatItems = [] - ItemsModel.shared.chatState.clear() + im.reversedChatItems = [] + im.chatState.clear() chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false @@ -292,124 +292,178 @@ struct ChatView: View { .onChange(of: colorScheme) { _ in theme = buildTheme() } - .toolbar { - ToolbarItem(placement: .principal) { - if selectedChatItems != nil { - SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) - } else if case let .direct(contact) = cInfo { - Button { - Task { - showChatInfoSheet = true - } - } label: { - ChatInfoToolbar(chat: chat) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - ChatInfoView( - chat: chat, - contact: contact, - localAlias: chat.chatInfo.localAlias, - featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - onSearch: { focusSearch() } - ) - } - } else if case let .group(groupInfo) = cInfo { - Button { - Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } - } label: { - ChatInfoToolbar(chat: chat) - .tint(theme.colors.primary) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - GroupChatInfoView( - chat: chat, - groupInfo: Binding( - get: { groupInfo }, - set: { gInfo in - chat.chatInfo = .group(groupInfo: gInfo) - chat.created = Date.now - } - ), - onSearch: { focusSearch() }, - localAlias: groupInfo.localAlias - ) - } - } else if case .local = cInfo { - ChatInfoToolbar(chat: chat) - } - } - ToolbarItem(placement: .navigationBarTrailing) { - if selectedChatItems != nil { - Button { - withAnimation { - selectedChatItems = nil - } - } label: { - Text("Cancel") - } - } else { - switch cInfo { - case let .direct(contact): - HStack { - let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } + .if(im.secondaryIMFilter == nil) { + $0.toolbar { + ToolbarItem(placement: .principal) { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else if case let .direct(contact) = cInfo { + Button { + Task { + showChatInfoSheet = true } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") + } label: { + ChatInfoToolbar(chat: chat) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + ChatInfoView( + chat: chat, + contact: contact, + localAlias: chat.chatInfo.localAlias, + featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + onSearch: { focusSearch() } + ) + } + } else if case let .group(groupInfo, _) = cInfo { + Button { + Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } + } label: { + ChatInfoToolbar(chat: chat) + .tint(theme.colors.primary) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + GroupChatInfoView( + chat: chat, + groupInfo: Binding( + get: { groupInfo }, + set: { gInfo in + chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil) + chat.created = Date.now } - .disabled(!contact.ready || !contact.active) - } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) - } label: { - Image(systemName: "ellipsis") - } + ), + onSearch: { focusSearch() }, + localAlias: groupInfo.localAlias + ) } - case let .group(groupInfo): - HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) + } else if case .local = cInfo { + ChatInfoToolbar(chat: chat) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } + } else { + switch cInfo { + case let .direct(contact): + HStack { + let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + if callsPrefEnabled { + if chatModel.activeCall == nil { + callButton(contact, .audio, imageName: "phone") + .disabled(!contact.ready || !contact.active) + } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } + } + Menu { + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") } - } else { - addMembersButton() + .disabled(!contact.ready || !contact.active) + } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) + } label: { + Image(systemName: "ellipsis") } } - Menu { - searchButton() - ToggleNtfsButton(chat: chat) - } label: { - Image(systemName: "ellipsis") + case let .group(groupInfo, _): + HStack { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + } + } + Menu { + searchButton() + ToggleNtfsButton(chat: chat) + } label: { + Image(systemName: "ellipsis") + } } + case .local: + searchButton() + default: + EmptyView() } - case .local: - searchButton() - default: - EmptyView() } } } } + .if(im.secondaryIMFilter != nil) { + $0.toolbar { + ToolbarItem(placement: .principal) { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else { + switch im.secondaryIMFilter { + case let .groupChatScopeContext(groupScopeInfo): + switch groupScopeInfo { + case let .memberSupport(groupMember_): + if let groupMember = groupMember_ { + MemberSupportChatToolbar(groupMember: groupMember) + } else { + textChatToolbar("Support") + } + } + case let .msgContentTagContext(contentTag): + switch contentTag { + case .report: + textChatToolbar("Member reports") + default: + EmptyView() + } + case .none: + EmptyView() + } + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } + } else { + searchButton() + } + } + } + } + } + + func textChatToolbar(_ text: LocalizedStringKey) -> some View { + HStack { + Text(text).font(.headline) + .lineLimit(1) + } + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) } private func initChatView() { @@ -448,13 +502,13 @@ struct ChatView: View { var index = mergedItems.boxedValue.indexInParentItems[itemId] if index == nil { let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2) - let oldSize = ItemsModel.shared.reversedChatItems.count + let oldSize = im.reversedChatItems.count let triedToLoad = await loadChatItems(chat, pagination) if !triedToLoad { return } var repeatsLeft = 50 - while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 { + while oldSize == im.reversedChatItems.count && repeatsLeft > 0 { try await Task.sleep(nanoseconds: 20_000000) repeatsLeft -= 1 } @@ -464,7 +518,7 @@ struct ChatView: View { closeKeyboardAndRun { Task { await MainActor.run { animatedScrollingInProgress = true } - await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) + await scrollView.scrollToItemAnimated(min(im.reversedChatItems.count - 1, index)) await MainActor.run { animatedScrollingInProgress = false } } } @@ -539,6 +593,7 @@ struct ChatView: View { ? (g.size.width - 32) : (g.size.width - 32) * 0.84 return ChatItemWithMenu( + im: im, chat: $chat, index: index, isLastItem: index == mergedItems.boxedValue.items.count - 1, @@ -571,7 +626,7 @@ struct ChatView: View { } } .onChange(of: im.reversedChatItems) { items in - mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) if im.itemAdded { im.itemAdded = false @@ -583,7 +638,7 @@ struct ChatView: View { } } .onChange(of: revealedItems) { revealed in - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealed) scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: chat.id) { _ in @@ -618,7 +673,7 @@ struct ChatView: View { private func updateWithInitiallyLoadedItems() { if mergedItems.boxedValue.items.isEmpty { - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, ItemsModel.shared.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) } let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } @@ -638,8 +693,8 @@ struct ChatView: View { private func searchTextChanged(_ s: String) { Task { - await loadChat(chat: chat, search: s) - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + await loadChat(chat: chat, im: im, search: s) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) await MainActor.run { scrollView.updateItems(mergedItems.boxedValue.items) } @@ -654,79 +709,8 @@ struct ChatView: View { } } - class FloatingButtonModel: ObservableObject { - @Published var unreadAbove: Int = 0 - @Published var unreadBelow: Int = 0 - @Published var isNearBottom: Bool = true - @Published var date: Date? = nil - @Published var isDateVisible: Bool = false - var hideDateWorkItem: DispatchWorkItem? = nil - - func updateOnListChange(_ listState: EndlessScrollView.ListState) { - let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState) - let unreadBelow = if let lastVisibleItem { - max(0, ItemsModel.shared.chatState.unreadTotal - lastVisibleItem.unreadBefore) - } else { - 0 - } - let unreadAbove = ItemsModel.shared.chatState.unreadTotal - unreadBelow - let date: Date? = - if let lastVisible = listState.visibleItems.last { - Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs) - } else { - nil - } - - // set the counters and date indicator - DispatchQueue.main.async { [weak self] in - guard let it = self else { return } - it.setDate(visibility: true) - it.unreadAbove = unreadAbove - it.unreadBelow = unreadBelow - it.date = date - } - - // set floating button indication mode - let nearBottom = listState.firstVisibleItemIndex < 1 - if nearBottom != self.isNearBottom { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in - self?.isNearBottom = nearBottom - } - } - - // hide Date indicator after 1 second of no scrolling - hideDateWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - guard let it = self else { return } - it.setDate(visibility: false) - it.hideDateWorkItem = nil - } - DispatchQueue.main.async { [weak self] in - self?.hideDateWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) - } - } - - func resetDate() { - date = nil - isDateVisible = false - } - - private func setDate(visibility isVisible: Bool) { - if isVisible { - if !isNearBottom, - !isDateVisible, - let date, !Calendar.current.isDateInToday(date) { - withAnimation { self.isDateVisible = true } - } - } else if isDateVisible { - withAnimation { self.isDateVisible = false } - } - } - - } - private struct FloatingButtons: View { + @ObservedObject var im: ItemsModel let theme: AppTheme let scrollView: EndlessScrollView let chat: Chat @@ -796,7 +780,7 @@ struct ChatView: View { } } .onTapGesture { - if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded { + if loadingBottomItems || !im.lastItemsLoaded { requestedTopScroll = false requestedBottomScroll = true } else { @@ -816,7 +800,7 @@ struct ChatView: View { } } .onChange(of: loadingBottomItems) { loading in - if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded { + if !loading && requestedBottomScroll && im.lastItemsLoaded { requestedBottomScroll = false scrollToBottom() } @@ -826,9 +810,9 @@ struct ChatView: View { private func scrollToTopUnread() { Task { - if !ItemsModel.shared.chatState.splits.isEmpty { + if !im.chatState.splits.isEmpty { await MainActor.run { loadingMoreItems = true } - await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false) + await loadChat(chatId: chat.id, im: im, openAroundItemId: nil, clearItems: false) await MainActor.run { reloadItems() } if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { await MainActor.run { animatedScrollingInProgress = true } @@ -938,7 +922,7 @@ struct ChatView: View { private func addMembersButton() -> some View { Button { - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { @@ -948,7 +932,7 @@ struct ChatView: View { private func groupLinkButton() -> some View { Button { - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { Task { do { if let link = try apiGetGroupLink(gInfo.groupId) { @@ -999,6 +983,7 @@ struct ChatView: View { let (validItems, confirmation) = try await apiPlanForwardChatItems( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemIds: Array(selectedChatItems) ) if let confirmation { @@ -1088,7 +1073,6 @@ struct ChatView: View { } func openForwardingSheet(_ items: [Int64]) async { - let im = ItemsModel.shared var items = Set(items) var fci = [ChatItem]() for reversedChatItem in im.reversedChatItems { @@ -1127,11 +1111,11 @@ struct ChatView: View { private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { await apiLoadMessages( chat.chatInfo.id, + im, pagination, - im.chatState, searchText, nil, - { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) } + { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) } ) return true } @@ -1143,11 +1127,12 @@ struct ChatView: View { func onChatItemsUpdated() { if !mergedItems.boxedValue.isActualState() { - //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(ItemsModel.shared.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(ItemsModel.shared.reversedChatItems.count)") + //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)") return } floatingButtonModel.updateOnListChange(scrollView.listState) preloadIfNeeded( + im, $allowLoadMoreItems, $ignoreLoadingRequests, scrollView.listState, @@ -1161,13 +1146,14 @@ struct ChatView: View { }, loadLastItems: { if !loadingMoreItems { - await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat, im) } } ) } private struct ChatItemWithMenu: View { + @ObservedObject var im: ItemsModel @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner @@ -1252,8 +1238,6 @@ struct ChatView: View { } var body: some View { - let im = ItemsModel.shared - let last = isLastItem ? im.reversedChatItems.last : nil let listItem = merged.newest() let item = listItem.item @@ -1298,11 +1282,17 @@ struct ChatView: View { if !itemIds.isEmpty { waitToMarkRead { await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions) + if (im.secondaryIMFilter != nil) { + m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id, by: itemIds.count ) + } } } } else if chatItem.isRcvNew { waitToMarkRead { await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) + if (im.secondaryIMFilter != nil) { + m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id) + } } } } @@ -1324,7 +1314,6 @@ struct ChatView: View { } private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { - let im = ItemsModel.shared var unreadItems: [ChatItem.ID] = [] var unreadMentions: Int = 0 @@ -1537,6 +1526,7 @@ struct ChatView: View { } ChatItemView( chat: chat, + im: im, chatItem: ci, scrollToItemId: scrollToItemId, maxWidth: maxWidth, @@ -1578,7 +1568,7 @@ struct ChatView: View { self.archivingReports = [] } } - if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive { + if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let reports = self.archivingReports { archiveReports(chat.chatInfo, reports.sorted(), true) @@ -1627,7 +1617,7 @@ struct ChatView: View { } } switch chat.chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): v.contextMenu { ReactionContextMenu( groupInfo: groupInfo, @@ -1650,7 +1640,7 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { + if case let .group(gInfo, _) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator { archiveReportButton(ci) } @@ -1709,7 +1699,7 @@ struct ChatView: View { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) } else if ci.meta.itemDeleted == nil && chat.groupFeatureEnabled(.reports), - case let .group(gInfo) = chat.chatInfo, + case let .group(gInfo, _) = chat.chatInfo, gInfo.membership.memberRole == .member && !live && composeState.voiceMessageRecordingState == .noRecording { @@ -1820,6 +1810,7 @@ struct ChatView: View { let chatItem = try await apiChatItemReaction( type: cInfo.chatType, id: cInfo.apiId, + scope: cInfo.groupChatScope(), itemId: ci.id, add: add, reaction: reaction @@ -1933,11 +1924,11 @@ struct ChatView: View { Task { do { let cInfo = chat.chatInfo - let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, itemId: ci.id) + let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id) await MainActor.run { chatItemInfo = ciInfo } - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { await m.loadGroupMembers(gInfo) } } catch let error { @@ -1991,13 +1982,13 @@ struct ChatView: View { private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button { Button(role: .destructive) { if !revealed, - let currIndex = m.getChatItemIndex(ci), + let currIndex = m.getChatItemIndex(im, ci), let ciCategory = ci.mergeCategory { let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) if let range = itemsRange(currIndex, prevHidden) { var itemIds: [Int64] = [] for i in range { - itemIds.append(ItemsModel.shared.reversedChatItems[i].id) + itemIds.append(im.reversedChatItems[i].id) } showDeleteMessages = true deletingItems = itemIds @@ -2135,12 +2126,12 @@ struct ChatView: View { selectedChatItems = selectedChatItems ?? [] var itemIds: [Int64] = [] if !revealed, - let currIndex = m.getChatItemIndex(ci), + let currIndex = m.getChatItemIndex(im, ci), let ciCategory = ci.mergeCategory { let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) if let range = itemsRange(currIndex, prevHidden) { for i in range { - itemIds.append(ItemsModel.shared.reversedChatItems[i].id) + itemIds.append(im.reversedChatItems[i].id) } } else { itemIds.append(ci.id) @@ -2174,6 +2165,7 @@ struct ChatView: View { try await apiDeleteChatItems( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemIds: [di.id], mode: mode ) @@ -2231,14 +2223,14 @@ struct ChatView: View { if searchIsNotBlank { goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) { closeKeyboardAndRun { - ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id) + im.loadOpenChatNoWait(chat.id, chatItem.id) } } } else if let chatTypeApiIdMsgId { goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) { closeKeyboardAndRun { let (chatType, apiId, msgId) = chatTypeApiIdMsgId - ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) } } } @@ -2265,6 +2257,84 @@ struct ChatView: View { } } +class FloatingButtonModel: ObservableObject { + @ObservedObject var im: ItemsModel + + public init(im: ItemsModel) { + self.im = im + } + + @Published var unreadAbove: Int = 0 + @Published var unreadBelow: Int = 0 + @Published var isNearBottom: Bool = true + @Published var date: Date? = nil + @Published var isDateVisible: Bool = false + var hideDateWorkItem: DispatchWorkItem? = nil + + func updateOnListChange(_ listState: EndlessScrollView.ListState) { + let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState) + let unreadBelow = if let lastVisibleItem { + max(0, im.chatState.unreadTotal - lastVisibleItem.unreadBefore) + } else { + 0 + } + let unreadAbove = im.chatState.unreadTotal - unreadBelow + let date: Date? = + if let lastVisible = listState.visibleItems.last { + Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs) + } else { + nil + } + + // set the counters and date indicator + DispatchQueue.main.async { [weak self] in + guard let it = self else { return } + it.setDate(visibility: true) + it.unreadAbove = unreadAbove + it.unreadBelow = unreadBelow + it.date = date + } + + // set floating button indication mode + let nearBottom = listState.firstVisibleItemIndex < 1 + if nearBottom != self.isNearBottom { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.isNearBottom = nearBottom + } + } + + // hide Date indicator after 1 second of no scrolling + hideDateWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let it = self else { return } + it.setDate(visibility: false) + it.hideDateWorkItem = nil + } + DispatchQueue.main.async { [weak self] in + self?.hideDateWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) + } + } + + func resetDate() { + date = nil + isDateVisible = false + } + + private func setDate(visibility isVisible: Bool) { + if isVisible { + if !isNearBottom, + !isDateVisible, + let date, !Calendar.current.isDateInToday(date) { + withAnimation { self.isDateVisible = true } + } + } else if isDateVisible { + withAnimation { self.isDateVisible = false } + } + } + +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } @@ -2286,6 +2356,7 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe try await apiDeleteChatItems( type: chatInfo.chatType, id: chatInfo.apiId, + scope: chatInfo.groupChatScope(), itemIds: itemIds, mode: mode ) @@ -2347,7 +2418,7 @@ private func buildTheme() -> AppTheme { if let cId = ChatModel.shared.chatId, let chat = ChatModel.shared.getChat(cId) { let perChatTheme = if case let .direct(contact) = chat.chatInfo { contact.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight) - } else if case let .group(groupInfo) = chat.chatInfo { + } else if case let .group(groupInfo, _) = chat.chatInfo { groupInfo.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight) } else { nil as ThemeModeOverride? @@ -2500,7 +2571,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { case var .direct(contact): contact.chatSettings = chatSettings ChatModel.shared.updateContact(contact) - case var .group(groupInfo): + case var .group(groupInfo, _): groupInfo.chatSettings = chatSettings ChatModel.shared.updateGroup(groupInfo) default: () @@ -2517,7 +2588,8 @@ struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.chatId = "@1" - ItemsModel.shared.reversedChatItems = [ + let im = ItemsModel.shared + im.reversedChatItems = [ ChatItem.getSample(1, .directSnd, .now, "hello"), ChatItem.getSample(2, .directRcv, .now, "hi"), ChatItem.getSample(3, .directRcv, .now, "hi there"), @@ -2529,7 +2601,12 @@ struct ChatView_Previews: PreviewProvider { ChatItem.getSample(9, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") ] @State var showChatInfo = false - return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) - .environmentObject(chatModel) + return ChatView( + chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im) + ) + .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 6ded9cae72..845023a161 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -323,6 +323,7 @@ struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool @Binding var keyboardHiddenDate: Date @@ -355,6 +356,20 @@ struct ComposeView: View { var body: some View { VStack(spacing: 0) { Divider() + if let groupInfo = chat.chatInfo.groupInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(member) = groupScopeInfo, + let member = member, + member.memberPending, + composeState.contextItem == .noContextItem, + composeState.noPreview { + ContextPendingMemberActionsView( + groupInfo: groupInfo, + member: member + ) + Divider() + } + if chat.chatInfo.contact?.nextSendGrpInv ?? false { ContextInvitingContactMemberView() Divider() @@ -396,7 +411,7 @@ struct ComposeView: View { .padding(.bottom, 16) .padding(.leading, 12) .tint(theme.colors.primary) - if case let .group(g) = chat.chatInfo, + if case let .group(g, _) = chat.chatInfo, !g.fullGroupPreferences.files.on(for: g.membership) { b.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( @@ -443,17 +458,30 @@ struct ComposeView: View { .padding(.trailing, 12) .disabled(!chat.userCanSend) - if chat.userIsObserver { - Text("you are observer") - .italic() - .foregroundColor(theme.colors.secondary) - .padding(.horizontal, 12) - .onTapGesture { - AlertManager.shared.showAlertMsg( - title: "You can't send messages!", - message: "Please contact group admin." - ) - } + if im.secondaryIMFilter == nil { + if chat.userIsPending { + Text("reviewed by moderators") + .italic() + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 12) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: "Please contact group admin." + ) + } + } else if chat.userIsObserver { + Text("you are observer") + .italic() + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 12) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: "Please contact group admin." + ) + } + } } } } @@ -944,6 +972,7 @@ struct ComposeView: View { let chatItem = try await apiUpdateChatItem( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemId: ei.id, updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions), live: live @@ -1031,6 +1060,7 @@ struct ComposeView: View { : await apiSendMessages( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), live: live, ttl: ttl, composedMessages: msgs @@ -1055,8 +1085,10 @@ struct ComposeView: View { if let chatItems = await apiForwardChatItems( toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, + toScope: chat.chatInfo.groupChatScope(), fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, + fromScope: fromChatInfo.groupChatScope(), itemIds: forwardedItems.map { $0.id }, ttl: ttl ) { @@ -1274,12 +1306,14 @@ struct ComposeView: View { struct ComposeView_Previews: PreviewProvider { static var previews: some View { let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) + let im = ItemsModel.shared @State var composeState = ComposeState(message: "hello") @State var selectedRange = NSRange() return Group { ComposeView( chat: chat, + im: im, composeState: $composeState, keyboardVisible: Binding.constant(true), keyboardHiddenDate: Binding.constant(Date.now), @@ -1288,6 +1322,7 @@ struct ComposeView_Previews: PreviewProvider { .environmentObject(ChatModel()) ComposeView( chat: chat, + im: im, composeState: $composeState, keyboardVisible: Binding.constant(true), keyboardHiddenDate: Binding.constant(Date.now), diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift new file mode 100644 index 0000000000..efb7cf1d96 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -0,0 +1,97 @@ +// +// ContextPendingMemberActionsView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 02.05.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [knocking] go back (close secondary ChatView) on actions +struct ContextPendingMemberActionsView: View { + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + var member: GroupMember + + var body: some View { + HStack(spacing: 0) { + ZStack { + Text("Remove") + .foregroundColor(.red) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + showRemoveMemberAlert(groupInfo, member) + } + + ZStack { + Text("Accept") + .foregroundColor(theme.colors.primary) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + showAcceptMemberAlert(groupInfo, member) + } + } + .frame(minHeight: 54) + .frame(maxWidth: .infinity) + .background(.thinMaterial) + } +} + +func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { + showAlert( + NSLocalizedString("Accept member", comment: "alert title"), + message: NSLocalizedString("Member will join the group, accept member?", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Accept as member", comment: "alert action"), + style: .default, + handler: { _ in + acceptMember(groupInfo, member, .member) + } + ), + UIAlertAction( + title: NSLocalizedString("Accept as observer", comment: "alert action"), + style: .default, + handler: { _ in + acceptMember(groupInfo, member, .observer) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default + ) + ]} + ) +} + +func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole) { + Task { + do { + let acceptedMember = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember) + } + } catch let error { + logger.error("apiAcceptMember error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting member", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + +#Preview { + ContextPendingMemberActionsView( + groupInfo: GroupInfo.sampleData, + member: GroupMember.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 9fa07bc391..bdc315a918 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -87,7 +87,26 @@ struct GroupChatInfoView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - + + Section { + if groupInfo.membership.supportChat != nil { + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + UserSupportChatNavLink( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + ) + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: [], chatStats: ChatStats()), + im: ItemsModel(secondaryIMFilter: .msgContentTagContext(contentTag: .report)) + ) + } + } + Section { if groupInfo.isOwner && groupInfo.businessChat == nil { editGroupButton() @@ -96,17 +115,6 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() - } - - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } } header: { Text("") } footer: { @@ -120,6 +128,16 @@ struct GroupChatInfoView: View { } Section { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() + } else { + sendReceiptsOptionDisabled() + } + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) } footer: { Text("Delete chat messages from your device.") @@ -519,6 +537,81 @@ struct GroupChatInfoView: View { .navigationBarTitleDisplayMode(.large) } + struct UserSupportChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @State private var userSupportChatNavLinkActive = false + @ObservedObject var chat: Chat + var im: ItemsModel + + var body: some View { + ZStack { + Button { + im.loadOpenChat(chat.id) { + userSupportChatNavLinkActive = true + } + } label: { + Label("Support chat", systemImage: "flag") + } + + NavigationLink(isActive: $userSupportChatNavLinkActive) { + if let secondaryIM = chatModel.secondaryIM { + SecondaryChatView( + chat: chat, + im: secondaryIM + ) + } + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + } + + private func memberSupportButton() -> some View { + NavigationLink { + MemberSupportView(groupInfo: groupInfo) + .navigationBarTitle("Member support") + .modifier(ThemedBackground()) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Member support", systemImage: "flag") + } + } + + struct GroupReportsChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @State private var groupReportsChatNavLinkActive = false + @ObservedObject var chat: Chat + var im: ItemsModel + + var body: some View { + ZStack { + Button { + im.loadOpenChat(chat.id) { + groupReportsChatNavLinkActive = true + } + } label: { + Label("Member reports", systemImage: "flag") + } + + NavigationLink(isActive: $groupReportsChatNavLinkActive) { + if let secondaryIM = chatModel.secondaryIM { + SecondaryChatView( + chat: chat, + im: secondaryIM + ) + } + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + } + private func editGroupButton() -> some View { NavigationLink { GroupProfileView( @@ -679,26 +772,34 @@ struct GroupChatInfoView: View { title: Text("Remove member?"), message: Text(messageLabel), primaryButton: .destructive(Text("Remove")) { - Task { - do { - let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) - await MainActor.run { - updatedMembers.forEach { updatedMember in - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) - } - } - } catch let error { - logger.error("apiRemoveMembers error: \(responseError(error))") - let a = getErrorAlert(error, "Error removing member") - alert = .error(title: a.title, error: a.message) - } - } + removeMember(groupInfo, mem) }, secondaryButton: .cancel() ) } } +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember) { + Task { + do { + let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + await MainActor.run { + updatedMembers.forEach { updatedMember in + _ = ChatModel.shared.upsertGroupMember(groupInfo, updatedMember) + } + } + } catch let error { + logger.error("apiRemoveMembers error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error removing member", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { groupInfo.businessChat == nil ? ( groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 79ad242366..ca9c0ecce3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -278,7 +278,7 @@ struct GroupMemberInfoView: View { } } .onChange(of: chat.chatInfo) { c in - if case let .group(gI) = chat.chatInfo { + if case let .group(gI, _) = chat.chatInfo { groupInfo = gI } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index ed39c401ce..3ad4de639d 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -30,6 +30,14 @@ struct GroupPreferencesView: View { let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" VStack { List { + Section { + MemberAdmissionButton( + groupInfo: $groupInfo, + admission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(), + currentAdmission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(), + creatingGroup: creatingGroup + ) + } featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) @@ -77,6 +85,62 @@ struct GroupPreferencesView: View { } } + struct MemberAdmissionButton: View { + @Binding var groupInfo: GroupInfo + @State var admission: GroupMemberAdmission + @State var currentAdmission: GroupMemberAdmission + var creatingGroup: Bool = false + + var body: some View { + NavigationLink { + MemberAdmissionView( + groupInfo: $groupInfo, + admission: $admission, + currentAdmission: currentAdmission, + creatingGroup: creatingGroup, + saveAdmission: saveAdmission + ) + .navigationBarTitle("Member admission") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + .onDisappear { + let saveText = NSLocalizedString( + creatingGroup ? "Save" : "Save and notify group members", + comment: "alert button" + ) + + if groupInfo.groupProfile.memberAdmission != admission { + showAlert( + title: NSLocalizedString("Save admission settings?", comment: "alert title"), + buttonTitle: saveText, + buttonAction: { saveAdmission() }, + cancelButton: true + ) + } + } + } label: { + Label("Member admission", systemImage: "switch.2") + } + } + + private func saveAdmission() { + Task { + do { + var gp = groupInfo.groupProfile + gp.memberAdmission = admission + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + currentAdmission = admission + } + } catch { + logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))") + } + } + } + } + private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift new file mode 100644 index 0000000000..dadf13d1a0 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift @@ -0,0 +1,92 @@ +// +// MemberAdmissionView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let memberCriterias: [(criteria: MemberCriteria?, text: LocalizedStringKey)] = [ + (nil, "off"), + (.all, "all") +] + +struct MemberAdmissionView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @Binding var groupInfo: GroupInfo + @Binding var admission: GroupMemberAdmission + var currentAdmission: GroupMemberAdmission + let creatingGroup: Bool + let saveAdmission: () -> Void + @State private var showSaveDialogue = false + + var body: some View { + let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" + VStack { + List { + admissionSection( + NSLocalizedString("Review", comment: "admission stage"), + NSLocalizedString("Review new members before admitting to group.", comment: "admission stage description"), + $admission.review + ) + + if groupInfo.isOwner { + Section { + Button("Reset") { admission = currentAdmission } + Button(saveText) { saveAdmission() } + } + .disabled(currentAdmission == admission) + } + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + if currentAdmission == admission { + dismiss() + } else { + showSaveDialogue = true + } + }) + .confirmationDialog("Save admission settings?", isPresented: $showSaveDialogue) { + Button(saveText) { + saveAdmission() + dismiss() + } + Button("Exit without saving") { + admission = currentAdmission + dismiss() + } + } + } + + private func admissionSection(_ admissionStageStr: String, _ admissionStageDescrStr: String, _ memberCriteria: Binding) -> some View { + Section { + if groupInfo.isOwner { + Picker(admissionStageStr, selection: memberCriteria) { + ForEach(memberCriterias, id: \.criteria) { mc in + Text(mc.text) + } + } + } else { + infoRow(Text(admissionStageStr), memberCriteria.wrappedValue?.text ?? NSLocalizedString("off", comment: "member criteria value")) + } + } footer: { + Text(admissionStageDescrStr) + .foregroundColor(theme.colors.secondary) + } + } +} + +#Preview { + MemberAdmissionView( + groupInfo: Binding.constant(GroupInfo.sampleData), + admission: Binding.constant(GroupMemberAdmission.sampleData), + currentAdmission: GroupMemberAdmission.sampleData, + creatingGroup: false, + saveAdmission: {} + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift new file mode 100644 index 0000000000..ef43950897 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -0,0 +1,44 @@ +// +// MemberSupportChatToolbar.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 01.05.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MemberSupportChatToolbar: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + var groupMember: GroupMember + var imageSize: CGFloat = 32 + + var body: some View { + return HStack { + MemberProfileImage(groupMember, size: imageSize) + .padding(.trailing, 4) + let t = Text(groupMember.displayName).font(.headline) + (groupMember.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + } + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) + } + + private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .baselineOffset(1) + .kerning(-2) + } +} + +#Preview { + MemberSupportChatToolbar( + groupMember: GroupMember.sampleData + ) + .environmentObject(CurrentColors.toAppTheme()) +} diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift new file mode 100644 index 0000000000..2a7f8e6f8a --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -0,0 +1,267 @@ +// +// MemberSupportView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MemberSupportView: View { + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State private var searchText: String = "" + @FocusState private var searchFocussed + var groupInfo: GroupInfo + + var body: some View { + viewBody() + .onAppear { + Task { + await chatModel.loadGroupMembers(groupInfo) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { + await chatModel.loadGroupMembers(groupInfo) + } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + } + + @ViewBuilder private func viewBody() -> some View { + let membersWithChats = sortedMembersWithChats() + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembersWithChats = s == "" + ? membersWithChats + : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + + if membersWithChats.isEmpty { + Text("No support chats") + .foregroundColor(.secondary) + } else { + List { + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + ForEach(filteredMembersWithChats) { memberWithChat in + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped) + MemberSupportChatNavLink( + groupInfo: groupInfo, + memberWithChat: memberWithChat, + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + ) + } + } + } + } + + struct MemberSupportChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @State private var memberSupportChatNavLinkActive = false + var groupInfo: GroupInfo + var memberWithChat: GMember + @ObservedObject var chat: Chat + var im: ItemsModel + + var body: some View { + ZStack { + Button { + im.loadOpenChat(chat.id) { + memberSupportChatNavLinkActive = true + } + } label: { + SupportChatRowView(groupMember: memberWithChat, groupInfo: groupInfo) + } + + NavigationLink(isActive: $memberSupportChatNavLinkActive) { + if let secondaryIM = chatModel.secondaryIM { + SecondaryChatView( + chat: chat, + im: secondaryIM + ) + } + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + } + + func sortedMembersWithChats() -> [GMember] { + chatModel.groupMembers + .filter { + $0.wrapped.supportChat != nil && + $0.wrapped.memberStatus != .memLeft && + $0.wrapped.memberStatus != .memRemoved + } + .sorted { (m0: GMember, m1: GMember) -> Bool in + if m0.wrapped.memberPending != m1.wrapped.memberPending { + return m0.wrapped.memberPending + } + + let mentions0 = (m0.wrapped.supportChat?.mentions ?? 0) > 0 + let mentions1 = (m1.wrapped.supportChat?.mentions ?? 0) > 0 + if mentions0 != mentions1 { + return mentions0 + } + + let attention0 = (m0.wrapped.supportChat?.memberAttention ?? 0) > 0 + let attention1 = (m1.wrapped.supportChat?.memberAttention ?? 0) > 0 + if attention0 != attention1 { + return attention0 + } + + let unread0 = (m0.wrapped.supportChat?.unread ?? 0) > 0 + let unread1 = (m1.wrapped.supportChat?.unread ?? 0) > 0 + if unread0 != unread1 { + return unread0 + } + + return (m0.wrapped.supportChat?.chatTs ?? .distantPast) > (m1.wrapped.supportChat?.chatTs ?? .distantPast) + } + } + + private struct SupportChatRowView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject var groupMember: GMember + @EnvironmentObject var theme: AppTheme + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var groupInfo: GroupInfo + + var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize } + + var body: some View { + let member = groupMember.wrapped + HStack{ + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground) + (member.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + Text(memberStatus(member)) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + + Spacer() + + if member.memberPending { + Image(systemName: "flag.fill") + .resizable() + .scaledToFill() + .frame(width: dynamicChatInfoSize * 0.8, height: dynamicChatInfoSize * 0.8) + .foregroundColor(theme.colors.primary) + } + if let supportChat = member.supportChat { + SupportChatUnreadIndicator(supportChat: supportChat) + } + } + // TODO [knocking] swipe actions are broken +// .swipeActions(edge: .trailing, allowsFullSwipe: true) { +// if member.memberPending { +// Button { +// showAcceptMemberAlert(groupInfo, member) +// } label: { +// Label("Accept", systemImage: "checkmark") +// } +// .tint(theme.colors.primary) +// } +// +// Button { +// showRemoveMemberAlert(groupInfo, member) +// } label: { +// Label("Remove", systemImage: "trash") +// } +// .tint(.red) +// } + } + + private func memberStatus(_ member: GroupMember) -> LocalizedStringKey { + if member.activeConn?.connDisabled ?? false { + return "disabled" + } else if member.activeConn?.connInactive ?? false { + return "inactive" + } else if member.memberPending { + return member.memberStatus.text + } else { + return LocalizedStringKey(member.memberRole.text) + } + } + + struct SupportChatUnreadIndicator: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var supportChat: GroupSupportChat + + var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize } + + private var indicatorTint: Color { + if supportChat.mentions > 0 || supportChat.memberAttention > 0 { + return theme.colors.primary + } else { + return theme.colors.secondary + } + } + + var body: some View { + HStack(alignment: .center, spacing: 2) { + if supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0 { + if supportChat.mentions > 0 && supportChat.unread > 1 { + Text("\(MENTION_START)") + .font(userFont <= .xxxLarge ? .body : .callout) + .foregroundColor(indicatorTint) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .cornerRadius(dynamicSize(userFont).unreadCorner) + .padding(.bottom, 1) + } + let singleUnreadIsMention = supportChat.mentions > 0 && supportChat.unread == 1 + (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(supportChat.unread)) + .font(userFont <= .xxxLarge ? .caption : .caption2) + .foregroundColor(.white) + .padding(.horizontal, dynamicSize(userFont).unreadPadding) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .background(indicatorTint) + .cornerRadius(dynamicSize(userFont).unreadCorner) + } + } + .frame(height: dynamicChatInfoSize) + .frame(minWidth: 22) + } + } + + private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(theme.colors.secondary) + } + } +} + +func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { + showAlert( + title: NSLocalizedString("Remove member?", comment: "alert title"), + buttonTitle: "Remove", + buttonAction: { removeMember(groupInfo, member) }, + cancelButton: true + ) +} + +#Preview { + MemberSupportView( + groupInfo: GroupInfo.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift new file mode 100644 index 0000000000..ef36cc3a31 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -0,0 +1,39 @@ +// +// SecondaryChatView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SecondaryChatView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel + + var body: some View { + ChatView( + chat: chat, + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im) + ) + .onDisappear { + chatModel.secondaryIM = nil + } + } +} + +#Preview { + SecondaryChatView( + chat: Chat( + chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), + chatItems: [], + chatStats: ChatStats() + ), + im: ItemsModel.shared + ) +} diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 85d6b279c5..e397970acd 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -25,7 +25,7 @@ struct SelectedItemsTopToolbar: View { struct SelectedItemsBottomToolbar: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme - let chatItems: [ChatItem] + let im: ItemsModel @Binding var selectedChatItems: Set? var chatInfo: ChatInfo // Bool - delete for everyone is possible @@ -75,9 +75,9 @@ struct SelectedItemsBottomToolbar: View { .resizable() .scaledToFit() .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red) + .foregroundColor(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : .red) } - .disabled(!moderateEnabled || deleteCountProhibited) + .disabled(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil) .opacity(canModerate ? 1 : 0) Spacer() @@ -88,24 +88,24 @@ struct SelectedItemsBottomToolbar: View { .resizable() .scaledToFit() .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary) + .foregroundColor(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : theme.colors.primary) } - .disabled(!forwardEnabled || forwardCountProhibited) + .disabled(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil) } .frame(maxHeight: .infinity) .padding([.leading, .trailing], 12) } .onAppear { - recheckItems(chatInfo, chatItems, selectedChatItems) + recheckItems(chatInfo, im.reversedChatItems, selectedChatItems) } .onChange(of: chatInfo) { info in - recheckItems(info, chatItems, selectedChatItems) + recheckItems(info, im.reversedChatItems, selectedChatItems) } - .onChange(of: chatItems) { items in + .onChange(of: im.reversedChatItems) { items in recheckItems(chatInfo, items, selectedChatItems) } .onChange(of: selectedChatItems) { selected in - recheckItems(chatInfo, chatItems, selected) + recheckItems(chatInfo, im.reversedChatItems, selected) } .frame(height: 55.5) .background(.thinMaterial) @@ -116,7 +116,7 @@ struct SelectedItemsBottomToolbar: View { deleteCountProhibited = count == 0 || count > 200 forwardCountProhibited = count == 0 || count > 20 canModerate = possibleToModerate(chatInfo) - let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo { + let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info, _) = chatInfo { info } else { nil @@ -145,7 +145,7 @@ struct SelectedItemsBottomToolbar: View { private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool { return switch chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): groupInfo.membership.memberRole >= .admin default: false } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 790af64b3f..210aa02290 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -66,7 +66,7 @@ struct ChatListNavLink: View { switch chat.chatInfo { case let .direct(contact): contactNavLink(contact) - case let .group(groupInfo): + case let .group(groupInfo, _): groupNavLink(groupInfo) case let .local(noteFolder): noteFolderNavLink(noteFolder) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 93c18f28cc..59364c9498 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -452,7 +452,13 @@ struct ChatListView: View { @ViewBuilder private func chatView() -> some View { if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { - ChatView(chat: chat) + let im = ItemsModel.shared + ChatView( + chat: chat, + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im) + ) } } @@ -898,12 +904,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted case .contactRequest: true case .contactConnection: true - case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer + case let .group(groupInfo, _): groupInfo.businessChat?.chatType == .customer default: false } case .groups: switch chatInfo { - case let .group(groupInfo): groupInfo.businessChat == nil + case let .group(groupInfo, _): groupInfo.businessChat == nil default: false } case .business: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 7f92862f66..40736e3887 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -141,7 +141,7 @@ struct ChatPreviewView: View { } else { EmptyView() } - case let .group(groupInfo): + case let .group(groupInfo, _): switch (groupInfo.membership.memberStatus) { case .memRejected: inactiveIcon() case .memLeft: inactiveIcon() @@ -165,7 +165,7 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil) - case let .group(groupInfo): + case let .group(groupInfo, _): let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary) @@ -335,7 +335,7 @@ struct ChatPreviewView: View { chatPreviewInfoText("connecting…") } } - case let .group(groupInfo): + case let .group(groupInfo, _): switch (groupInfo.membership.memberStatus) { case .memRejected: chatPreviewInfoText("rejected") case .memInvited: groupInvitationPreviewText(groupInfo) @@ -445,6 +445,8 @@ struct ChatPreviewView: View { ProgressView() } else if chat.chatStats.reportsCount > 0 { groupReportsIcon(size: size * 0.8) + } else if chat.chatStats.supportChatsUnreadCount > 0 { + GroupSupportUnreadIcon(size: size * 0.8) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } @@ -498,6 +500,19 @@ struct ChatPreviewView: View { .foregroundColor(.red) } +struct GroupSupportUnreadIcon: View { + @EnvironmentObject var theme: AppTheme + var size: CGFloat + + var body: some View { + Image(systemName: "flag") + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundColor(theme.colors.primary) + } +} + func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { view() .frame(width: size, height: size) diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 16ab26eff7..c21ff9be8b 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -66,6 +66,8 @@ struct LocalAuthView: View { m.chatId = nil ItemsModel.shared.reversedChatItems = [] ItemsModel.shared.chatState.clear() + ChatModel.shared.secondaryIM?.reversedChatItems = [] + ChatModel.shared.secondaryIM?.chatState.clear() m.updateChats([]) m.users = [] _ = kcAppPassword.set(password) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 87c0b80372..b3c33e95ea 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -193,7 +193,7 @@ struct AddGroupView: View { Task { await m.loadGroupMembers(gInfo) } - let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) + let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: []) m.addChat(c) withAnimation { groupInfo = gInfo diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index c6d0e27289..df6210496b 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -367,13 +367,13 @@ struct ChatThemePreview: View { let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview")) let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)) HStack { - ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: alice, scrollToItemId: { _ in }) .modifier(ChatItemClipped(alice, tailVisible: true)) Spacer() } HStack { Spacer() - ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItemId: { _ in }) .modifier(ChatItemClipped(bob, tailVisible: true)) .frame(alignment: .trailing) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 961cad128f..20bb375d0c 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -59,6 +59,7 @@ let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" +let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" let DEFAULT_MIGRATION_TO_STAGE = "migrationToStage" @@ -118,6 +119,7 @@ let appDefaults: [String: Any] = [ DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, + DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, @@ -145,6 +147,7 @@ let hintDefaults = [ DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, DEFAULT_SHOW_MUTE_PROFILE_ALERT, + DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE, DEFAULT_SHOW_DELETE_CONTACT_NOTICE ] diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 56f1c2f5f3..309ca14ae5 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -67,6 +67,7 @@ func apiSendMessages( : SEChatCommand.apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, + scope: chatInfo.groupChatScope(), live: false, ttl: nil, composedMessages: composedMessages @@ -123,7 +124,7 @@ enum SEChatCommand: ChatCmdProtocol { case apiSetEncryptLocalFiles(enable: Bool) case apiGetChats(userId: Int64) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) - case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) var cmdString: String { switch self { @@ -139,15 +140,27 @@ enum SEChatCommand: ChatCmdProtocol { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" - case let .apiSendMessages(type, id, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" } } - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" + func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String { + "\(type.rawValue)\(id)\(scopeRef(scope: scope))" + } + + func scopeRef(scope: GroupChatScope?) -> String { + switch (scope) { + case .none: "" + case let .memberSupport(groupMemberId_): + if let groupMemberId = groupMemberId_ { + "(_support:\(groupMemberId))" + } else { + "(_support)" + } + } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c7fd19e615..d4560d333a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -168,6 +168,11 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; + 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; }; + 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */; }; + 64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */; }; + 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */; }; + 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; @@ -527,6 +532,11 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; 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 = ""; }; + 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = ""; }; + 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportView.swift; sourceTree = ""; }; + 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryChatView.swift; sourceTree = ""; }; + 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportChatToolbar.swift; sourceTree = ""; }; + 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; @@ -1076,6 +1086,7 @@ 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */, D72A9087294BD7A70047C86D /* NativeTextEditor.swift */, 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */, + 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -1117,6 +1128,10 @@ 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */, 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */, B70CE9E52D4BE5930080F36D /* GroupMentions.swift */, + 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */, + 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */, + 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */, + 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, ); path = Group; sourceTree = ""; @@ -1431,8 +1446,10 @@ 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, + 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, + 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */, 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */, @@ -1467,6 +1484,7 @@ 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, + 64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, @@ -1509,6 +1527,7 @@ 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */, 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, + 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */, 8C74C3EA2C1B90AF00039E77 /* ThemeManager.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, @@ -1587,6 +1606,7 @@ 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, + 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 35c276b2f4..81842d552f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1197,7 +1197,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) - case group(groupInfo: GroupInfo) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) case local(noteFolder: NoteFolder) case contactRequest(contactRequest: UserContactRequest) case contactConnection(contactConnection: PendingContactConnection) @@ -1211,7 +1211,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.localDisplayName - case let .group(groupInfo): return groupInfo.localDisplayName + case let .group(groupInfo, _): return groupInfo.localDisplayName case .local: return "" case let .contactRequest(contactRequest): return contactRequest.localDisplayName case let .contactConnection(contactConnection): return contactConnection.localDisplayName @@ -1224,7 +1224,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.displayName - case let .group(groupInfo): return groupInfo.displayName + case let .group(groupInfo, _): return groupInfo.displayName case .local: return ChatInfo.privateNotesChatName case let .contactRequest(contactRequest): return contactRequest.displayName case let .contactConnection(contactConnection): return contactConnection.displayName @@ -1237,7 +1237,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.fullName - case let .group(groupInfo): return groupInfo.fullName + case let .group(groupInfo, _): return groupInfo.fullName case .local: return "" case let .contactRequest(contactRequest): return contactRequest.fullName case let .contactConnection(contactConnection): return contactConnection.fullName @@ -1250,7 +1250,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.image - case let .group(groupInfo): return groupInfo.image + case let .group(groupInfo, _): return groupInfo.image case .local: return nil case let .contactRequest(contactRequest): return contactRequest.image case let .contactConnection(contactConnection): return contactConnection.image @@ -1263,7 +1263,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.localAlias - case let .group(groupInfo): return groupInfo.localAlias + case let .group(groupInfo, _): return groupInfo.localAlias case .local: return "" case let .contactRequest(contactRequest): return contactRequest.localAlias case let .contactConnection(contactConnection): return contactConnection.localAlias @@ -1276,7 +1276,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.id - case let .group(groupInfo): return groupInfo.id + case let .group(groupInfo, _): return groupInfo.id case let .local(noteFolder): return noteFolder.id case let .contactRequest(contactRequest): return contactRequest.id case let .contactConnection(contactConnection): return contactConnection.id @@ -1302,7 +1302,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.apiId - case let .group(groupInfo): return groupInfo.apiId + case let .group(groupInfo, _): return groupInfo.apiId case let .local(noteFolder): return noteFolder.apiId case let .contactRequest(contactRequest): return contactRequest.apiId case let .contactConnection(contactConnection): return contactConnection.apiId @@ -1315,7 +1315,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.ready - case let .group(groupInfo): return groupInfo.ready + case let .group(groupInfo, _): return groupInfo.ready case let .local(noteFolder): return noteFolder.ready case let .contactRequest(contactRequest): return contactRequest.ready case let .contactConnection(contactConnection): return contactConnection.ready @@ -1337,7 +1337,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.sendMsgEnabled - case let .group(groupInfo): return groupInfo.sendMsgEnabled + case let .group(groupInfo, _): return groupInfo.sendMsgEnabled case let .local(noteFolder): return noteFolder.sendMsgEnabled case let .contactRequest(contactRequest): return contactRequest.sendMsgEnabled case let .contactConnection(contactConnection): return contactConnection.sendMsgEnabled @@ -1350,7 +1350,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.contactConnIncognito - case let .group(groupInfo): return groupInfo.membership.memberIncognito + case let .group(groupInfo, _): return groupInfo.membership.memberIncognito case .local: return false case .contactRequest: return false case let .contactConnection(contactConnection): return contactConnection.incognito @@ -1375,7 +1375,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupInfo: GroupInfo? { switch self { - case let .group(groupInfo): return groupInfo + case let .group(groupInfo, _): return groupInfo default: return nil } } @@ -1392,7 +1392,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .voice: return cups.voice.enabled.forUser case .calls: return cups.calls.enabled.forUser } - case let .group(groupInfo): + case let .group(groupInfo, _): let prefs = groupInfo.fullGroupPreferences switch feature { case .timedMessages: return prefs.timedMessages.on @@ -1415,7 +1415,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case let .direct(contact): let pref = contact.mergedPreferences.timedMessages return pref.enabled.forUser ? pref.userPreference.preference.ttl : nil - case let .group(groupInfo): + case let .group(groupInfo, _): let pref = groupInfo.fullGroupPreferences.timedMessages return pref.on ? pref.ttl : nil default: @@ -1440,7 +1440,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } else { return .other } - case let .group(groupInfo): + case let .group(groupInfo, _): if !groupInfo.fullGroupPreferences.voice.on(for: groupInfo.membership) { return .groupOwnerCan } else { @@ -1471,7 +1471,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { return .other } } - + + public func groupChatScope() -> GroupChatScope? { + switch self { + case let .group(_, groupChatScope): groupChatScope?.toChatScope() + default: nil + } + } + public func ntfsEnabled(chatItem: ChatItem) -> Bool { ntfsEnabled(chatItem.meta.userMention) } @@ -1487,7 +1494,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatSettings: ChatSettings? { switch self { case let .direct(contact): return contact.chatSettings - case let .group(groupInfo): return groupInfo.chatSettings + case let .group(groupInfo, _): return groupInfo.chatSettings default: return nil } } @@ -1503,7 +1510,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatTags: [Int64]? { switch self { case let .direct(contact): return contact.chatTags - case let .group(groupInfo): return groupInfo.chatTags + case let .group(groupInfo, _): return groupInfo.chatTags default: return nil } } @@ -1511,7 +1518,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { var createdAt: Date { switch self { case let .direct(contact): return contact.createdAt - case let .group(groupInfo): return groupInfo.createdAt + case let .group(groupInfo, _): return groupInfo.createdAt case let .local(noteFolder): return noteFolder.createdAt case let .contactRequest(contactRequest): return contactRequest.createdAt case let .contactConnection(contactConnection): return contactConnection.createdAt @@ -1522,7 +1529,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var updatedAt: Date { switch self { case let .direct(contact): return contact.updatedAt - case let .group(groupInfo): return groupInfo.updatedAt + case let .group(groupInfo, _): return groupInfo.updatedAt case let .local(noteFolder): return noteFolder.updatedAt case let .contactRequest(contactRequest): return contactRequest.updatedAt case let .contactConnection(contactConnection): return contactConnection.updatedAt @@ -1533,7 +1540,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatTs: Date { switch self { case let .direct(contact): return contact.chatTs ?? contact.updatedAt - case let .group(groupInfo): return groupInfo.chatTs ?? groupInfo.updatedAt + case let .group(groupInfo, _): return groupInfo.chatTs ?? groupInfo.updatedAt case let .local(noteFolder): return noteFolder.chatTs case let .contactRequest(contactRequest): return contactRequest.updatedAt case let .contactConnection(contactConnection): return contactConnection.updatedAt @@ -1549,7 +1556,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } else { ChatTTL.userDefault(globalTTL) } - case let .group(groupInfo): + case let .group(groupInfo, _): return if let ciTTL = groupInfo.chatItemTTL { ChatTTL.chat(ChatItemTTL(ciTTL)) } else { @@ -1569,7 +1576,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public static var sampleData: ChatInfo.SampleData = SampleData( direct: ChatInfo.direct(contact: Contact.sampleData), - group: ChatInfo.group(groupInfo: GroupInfo.sampleData), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData, groupChatScope: nil), local: ChatInfo.local(noteFolder: NoteFolder.sampleData), contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData), contactConnection: ChatInfo.contactConnection(contactConnection: PendingContactConnection.getSampleData()) @@ -1599,10 +1606,18 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } public struct ChatStats: Decodable, Hashable { - public init(unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { + public init( + unreadCount: Int = 0, + unreadMentions: Int = 0, + reportsCount: Int = 0, + supportChatsUnreadCount: Int = 0, + minUnreadItemId: Int64 = 0, + unreadChat: Bool = false + ) { self.unreadCount = unreadCount self.unreadMentions = unreadMentions self.reportsCount = reportsCount + self.supportChatsUnreadCount = supportChatsUnreadCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } @@ -1611,11 +1626,39 @@ public struct ChatStats: Decodable, Hashable { public var unreadMentions: Int = 0 // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 + // actual only via getChats() and getChat(.initial), otherwise, zero + public var supportChatsUnreadCount: Int = 0 public var minUnreadItemId: Int64 = 0 // actual only via getChats(), otherwise, false public var unreadChat: Bool = false } +public enum GroupChatScope: Decodable { + case memberSupport(groupMemberId_: Int64?) +} + +public func sameChatScope(_ scope1: GroupChatScope, _ scope2: GroupChatScope) -> Bool { + switch (scope1, scope2) { + case let (.memberSupport(groupMemberId1_), .memberSupport(groupMemberId2_)): + return groupMemberId1_ == groupMemberId2_ + } +} + +public enum GroupChatScopeInfo: Decodable, Hashable { + case memberSupport(groupMember_: GroupMember?) + + public func toChatScope() -> GroupChatScope { + switch self { + case let .memberSupport(groupMember_): + if let groupMember = groupMember_ { + return .memberSupport(groupMemberId_: groupMember.groupMemberId) + } else { + return .memberSupport(groupMemberId_: nil) + } + } + } +} + public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var contactId: Int64 var localDisplayName: ContactName @@ -2010,6 +2053,10 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { return membership.memberRole >= .admin && membership.memberActive } + public var canModerate: Bool { + return membership.memberRole >= .moderator && membership.memberActive + } + public static let sampleData = GroupInfo( groupId: 1, localDisplayName: "team", @@ -2030,12 +2077,20 @@ public struct GroupRef: Decodable, Hashable { } public struct GroupProfile: Codable, NamedChat, Hashable { - public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { + public init( + displayName: String, + fullName: String, + description: String? = nil, + image: String? = nil, + groupPreferences: GroupPreferences? = nil, + memberAdmission: GroupMemberAdmission? = nil + ) { self.displayName = displayName self.fullName = fullName self.description = description self.image = image self.groupPreferences = groupPreferences + self.memberAdmission = memberAdmission } public var displayName: String @@ -2043,6 +2098,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var description: String? public var image: String? public var groupPreferences: GroupPreferences? + public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } public static let sampleData = GroupProfile( @@ -2051,6 +2107,34 @@ public struct GroupProfile: Codable, NamedChat, Hashable { ) } +public struct GroupMemberAdmission: Codable, Hashable { + public var review: MemberCriteria? + + public init( + review: MemberCriteria? = nil + ) { + self.review = review + } + + public static let sampleData = GroupMemberAdmission( + review: .all + ) +} + +public enum MemberCriteria: String, Codable, Identifiable, Hashable { + case all + + public static var values: [MemberCriteria] { [.all] } + + public var id: Self { self } + + public var text: String { + switch self { + case .all: return NSLocalizedString("all", comment: "member criteria value") + } + } +} + public struct BusinessChatInfo: Decodable, Hashable { public var chatType: BusinessChatType public var businessId: String @@ -2077,6 +2161,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var supportChat: GroupSupportChat? public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } @@ -2148,6 +2233,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { case .memUnknown: return false case .memInvited: return false case .memPendingApproval: return true + case .memPendingReview: return true case .memIntroduced: return false case .memIntroInvited: return false case .memAccepted: return false @@ -2167,6 +2253,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { case .memUnknown: return false case .memInvited: return false case .memPendingApproval: return false + case .memPendingReview: return false case .memIntroduced: return true case .memIntroInvited: return true case .memAccepted: return true @@ -2177,6 +2264,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable { } } + public var memberPending: Bool { + switch memberStatus { + case .memPendingApproval: return true + case .memPendingReview: return true + default: return false + } + } + public func canBeRemoved(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole return memberStatus != .memRemoved && memberStatus != .memLeft @@ -2230,6 +2325,13 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public struct GroupSupportChat: Codable, Hashable { + public var chatTs: Date + public var unread: Int + public var memberAttention: Int + public var mentions: Int +} + public struct GroupMemberSettings: Codable, Hashable { public var showMessages: Bool } @@ -2299,6 +2401,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case memUnknown = "unknown" case memInvited = "invited" case memPendingApproval = "pending_approval" + case memPendingReview = "pending_review" case memIntroduced = "introduced" case memIntroInvited = "intro-inv" case memAccepted = "accepted" @@ -2316,6 +2419,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case .memUnknown: return "unknown status" case .memInvited: return "invited" case .memPendingApproval: return "pending approval" + case .memPendingReview: return "pending review" case .memIntroduced: return "connecting (introduced)" case .memIntroInvited: return "connecting (introduction invitation)" case .memAccepted: return "connecting (accepted)" @@ -2335,6 +2439,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case .memUnknown: return "unknown" case .memInvited: return "invited" case .memPendingApproval: return "pending" + case .memPendingReview: return "review" case .memIntroduced: return "connecting" case .memIntroInvited: return "connecting" case .memAccepted: return "connecting" @@ -2589,12 +2694,14 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .userDeleted: nil case .groupDeleted: nil case .memberCreatedContact: nil + case .newMemberPendingReview: nil default: .rcvGroupEvent } case let .sndGroupEvent(event): switch event { case .userRole: nil case .userLeft: nil + case .userPendingReview: nil default: .sndGroupEvent } default: @@ -2627,6 +2734,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { switch rcvGroupEvent { case .groupUpdated: return false case .memberConnected: return false + case .memberAccepted: return false + case .userAccepted: return false case .memberRole: return false case .memberBlocked: return false case .userRole: return true @@ -2638,6 +2747,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .invitedViaGroupLink: return false case .memberCreatedContact: return false case .memberProfileUpdated: return false + case .newMemberPendingReview: return true } case .sndGroupEvent: return false case .rcvConnEvent: return false @@ -2706,12 +2816,12 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember?)? { switch (chatInfo, chatDir) { - case let (.group(groupInfo), .groupRcv(groupMember)): + case let (.group(groupInfo, _), .groupRcv(groupMember)): let m = groupInfo.membership return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil ? (groupInfo, groupMember) : nil - case let (.group(groupInfo), .groupSnd): + case let (.group(groupInfo, _), .groupSnd): let m = groupInfo.membership return m.memberRole >= .admin ? (groupInfo, nil) : nil default: return nil @@ -3093,6 +3203,21 @@ public enum CIStatus: Decodable, Hashable { } } + // as in corresponds to SENT response from agent, opposed to `sent` which means snd status + public var isSent: Bool { + switch self { + case .sndNew: false + case .sndSent: true + case .sndRcvd: false + case .sndErrorAuth: true + case .sndError: true + case .sndWarning: true + case .rcvNew: false + case .rcvRead: false + case .invalid: false + } + } + public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? { switch self { case .sndNew: nil @@ -3146,6 +3271,17 @@ public enum CIStatus: Decodable, Hashable { } } +public func shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus) -> Bool { + switch (oldStatus, newStatus) { + case (.sndRcvd, let new) where !new.isSndRcvd: + return true + case (let old, .sndNew) where old.isSent: + return true + default: + return false + } +} + public enum SndError: Decodable, Hashable { case auth case quota @@ -4045,6 +4181,16 @@ extension MsgContent: Encodable { } } +public enum MsgContentTag: String { + case text + case link + case image + case video + case voice + case file + case report +} + public struct FormattedText: Decodable, Hashable { public var text: String public var format: Format? @@ -4376,6 +4522,8 @@ public enum RcvDirectEvent: Decodable, Hashable { public enum RcvGroupEvent: Decodable, Hashable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected + case memberAccepted(groupMemberId: Int64, profile: Profile) + case userAccepted case memberLeft case memberRole(groupMemberId: Int64, profile: Profile, role: GroupMemberRole) case memberBlocked(groupMemberId: Int64, profile: Profile, blocked: Bool) @@ -4387,12 +4535,16 @@ public enum RcvGroupEvent: Decodable, Hashable { case invitedViaGroupLink case memberCreatedContact case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) + case newMemberPendingReview var text: String { switch self { case let .memberAdded(_, profile): return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName) case .memberConnected: return NSLocalizedString("member connected", comment: "rcv group event chat item") + case let .memberAccepted(_, profile): + return String.localizedStringWithFormat(NSLocalizedString("accepted %@", comment: "rcv group event chat item"), profile.profileViewName) + case .userAccepted: return NSLocalizedString("accepted you", comment: "rcv group event chat item") case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item") case let .memberRole(_, profile, role): return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text) @@ -4412,6 +4564,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item") case .memberCreatedContact: return NSLocalizedString("connected directly", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) + case .newMemberPendingReview: return NSLocalizedString("New member wants to join the group.", comment: "rcv group event chat item") } } @@ -4436,6 +4589,7 @@ public enum SndGroupEvent: Decodable, Hashable { case memberDeleted(groupMemberId: Int64, profile: Profile) case userLeft case groupUpdated(groupProfile: GroupProfile) + case userPendingReview var text: String { switch self { @@ -4453,6 +4607,8 @@ public enum SndGroupEvent: Decodable, Hashable { return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName) case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item") + case .userPendingReview: + return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item") } } } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 6cbc76ec98..f27485a0f6 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -16,7 +16,7 @@ public protocol ChatLike { extension ChatLike { public func groupFeatureEnabled(_ feature: GroupFeature) -> Bool { - if case let .group(groupInfo) = self.chatInfo { + if case let .group(groupInfo, _) = self.chatInfo { let p = groupInfo.fullGroupPreferences return switch feature { case .timedMessages: p.timedMessages.on @@ -83,7 +83,7 @@ public func foundChat(_ chat: ChatLike, _ searchStr: String) -> Bool { private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { switch cInfo { case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv - case let .group(groupInfo): groupInfo.sendMsgEnabled + case let .group(groupInfo, _): groupInfo.sendMsgEnabled case let .local(noteFolder): noteFolder.sendMsgEnabled case .contactRequest: false case .contactConnection: false @@ -94,7 +94,7 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { public func chatIconName(_ cInfo: ChatInfo) -> String { switch cInfo { case .direct: "person.crop.circle.fill" - case let .group(groupInfo): + case let .group(groupInfo, _): switch groupInfo.businessChat?.chatType { case .none: "person.2.circle.fill" case .business: "briefcase.circle.fill" diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 5579449caa..70db4476d5 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -62,7 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String - if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { + if case let .group(groupInfo, _) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: previewMode == .hidden) } else { title = previewMode == .hidden ? contactHidden : "\(cInfo.chatViewName):" 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 bc70933571..ec288abb31 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 @@ -3103,6 +3103,7 @@ sealed class CIStatus { @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus() + // as in corresponds to SENT response from agent fun isSent(): Boolean = when(this) { is SndNew -> false is SndSent -> true 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 d69b6728b9..46b0c05a31 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 @@ -165,7 +165,7 @@ fun SupportChatRow(member: GroupMember) { fun memberStatus(): String { return if (member.activeConn?.connDisabled == true) { generalGetString(MR.strings.member_info_member_disabled) - } else if (member.activeConn?.connDisabled == true) { + } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) } else if (member.memberPending) { member.memberStatus.text From 0ea1363a2791cc3afa47e4593d76c908d85e4991 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 5 May 2025 12:32:55 +0400 Subject: [PATCH 16/72] ios: dynamically mark items read --- apps/ios/Shared/Model/ChatModel.swift | 24 +++++++++---------- apps/ios/Shared/Model/SimpleXAPI.swift | 10 ++++---- apps/ios/Shared/Views/Chat/ChatView.swift | 6 ++--- .../Views/ChatList/ChatListNavLink.swift | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3c27a9875b..9859e518e5 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -809,7 +809,7 @@ final class ChatModel: ObservableObject { im.reversedChatItems.first?.isLiveDummy == true } - func markAllChatItemsRead(_ cInfo: ChatInfo) { + func markAllChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in self.decreaseUnreadCounter(user: self.currentUser!, chat: chat) @@ -820,7 +820,7 @@ final class ChatModel: ObservableObject { if chatId == cInfo.id { var i = 0 while i < im.reversedChatItems.count { - markChatItemRead_(i) + markChatItemRead_(chatIM, i) i += 1 } im.chatState.itemsRead(nil, im.reversedChatItems.reversed()) @@ -850,21 +850,21 @@ final class ChatModel: ObservableObject { } } - func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { + func markChatItemsRead(_ chatIM: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], _ mentionsRead: Int) { if self.chatId == cInfo.id { var unreadItemIds: Set = [] var i = 0 var ids = Set(itemIds) - while i < im.reversedChatItems.count && !ids.isEmpty { - let item = im.reversedChatItems[i] + while i < chatIM.reversedChatItems.count && !ids.isEmpty { + let item = chatIM.reversedChatItems[i] if ids.contains(item.id) && item.isRcvNew { - markChatItemRead_(i) + markChatItemRead_(chatIM, i) unreadItemIds.insert(item.id) ids.remove(item.id) } i += 1 } - im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed()) + chatIM.chatState.itemsRead(unreadItemIds, chatIM.reversedChatItems.reversed()) } self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) } @@ -960,13 +960,13 @@ final class ChatModel: ObservableObject { } } - private func markChatItemRead_(_ i: Int) { - let meta = im.reversedChatItems[i].meta + private func markChatItemRead_(_ chatIM: ItemsModel, _ i: Int) { + let meta = chatIM.reversedChatItems[i].meta if case .rcvNew = meta.itemStatus { - im.reversedChatItems[i].meta.itemStatus = .rcvRead - im.reversedChatItems[i].viewTimestamp = .now + chatIM.reversedChatItems[i].meta.itemStatus = .rcvRead + chatIM.reversedChatItems[i].viewTimestamp = .now if meta.itemLive != true, let ttl = meta.itemTimed?.ttl { - im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) + chatIM.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e0e5b46176..8bd9a56a5c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1541,13 +1541,13 @@ func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { throw r } -func markChatRead(_ chat: Chat) async { +func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope()) await MainActor.run { - withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) } + withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) } } } if chat.chatStats.unreadChat { @@ -1570,11 +1570,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { } } -func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { +func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) - DispatchQueue.main.async { - ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead) + await MainActor.run { + ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead) } } catch { logger.error("apiChatItemsRead error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 11a54f6fb7..7d5de2f1e2 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -755,7 +755,7 @@ struct ChatView: View { .contextMenu { Button { Task { - await markChatRead(chat) + await markChatRead(im, chat) } } label: { Label("Mark read", systemImage: "checkmark") @@ -1281,7 +1281,7 @@ struct ChatView: View { let (itemIds, unreadMentions) = unreadItemIds(range) if !itemIds.isEmpty { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions) + await apiMarkChatItemsRead(im, chat.chatInfo, itemIds, mentionsRead: unreadMentions) if (im.secondaryIMFilter != nil) { m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id, by: itemIds.count ) } @@ -1289,7 +1289,7 @@ struct ChatView: View { } } else if chatItem.isRcvNew { waitToMarkRead { - await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) + await apiMarkChatItemsRead(im, chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) if (im.secondaryIMFilter != nil) { m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 210aa02290..4ac58a4913 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -276,7 +276,7 @@ struct ChatListNavLink: View { @ViewBuilder private func markReadButton() -> some View { if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat { Button { - Task { await markChatRead(chat) } + Task { await markChatRead(ItemsModel.shared, chat) } } label: { SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } From 28a588628cf1c78a18a3060bca98ae3eb9a04f66 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 5 May 2025 09:32:57 +0000 Subject: [PATCH 17/72] ios: correct list of members possible to mention in support scope (#5870) --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/Chat/Group/GroupMentions.swift | 28 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 7d5de2f1e2..d585d88791 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -89,7 +89,7 @@ struct ChatView: View { ZStack(alignment: .bottomTrailing) { chatItemsList() if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { - GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) + GroupMentionsView(im: im, groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) } FloatingButtons(im: im, theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: { mergedItems.boxedValue = MergedItems.create(im, revealedItems) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index 168f0490c3..92c91d9e8a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -17,6 +17,7 @@ let MAX_VISIBLE_MEMBER_ROWS: CGFloat = 4.8 struct GroupMentionsView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme + var im: ItemsModel var groupInfo: GroupInfo @Binding var composeState: ComposeState @Binding var selectedRange: NSRange @@ -93,12 +94,31 @@ struct GroupMentionsView: View { currentMessage = composeState.message } } - + + func contextMemberFilter(_ member: GroupMember) -> Bool { + switch im.secondaryIMFilter { + case nil: + return true + case let .groupChatScopeContext(groupScopeInfo): + switch (groupScopeInfo) { + case let .memberSupport(groupMember_): + if let scopeMember = groupMember_ { + return member.memberRole >= .moderator || member.groupMemberId == scopeMember.groupMemberId + } else { + return member.memberRole >= .moderator + } + } + case .msgContentTagContext: + return false + } + } + private func filteredMembers() -> [GMember] { let s = mentionName.lowercased() - return s.isEmpty - ? sortedMembers - : sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + return sortedMembers.filter { + contextMemberFilter($0.wrapped) + && (s.isEmpty || $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s)) + } } private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) { From 3aa6d6c3b31395c1541369b1957ad403a0dbee80 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 5 May 2025 13:43:00 +0400 Subject: [PATCH 18/72] ios: fix member support swipe actions --- .../Views/Chat/Group/MemberSupportView.swift | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 2a7f8e6f8a..ea07532c22 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -65,6 +65,7 @@ struct MemberSupportView: View { struct MemberSupportChatNavLink: View { @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme @State private var memberSupportChatNavLinkActive = false var groupInfo: GroupInfo var memberWithChat: GMember @@ -94,6 +95,23 @@ struct MemberSupportView: View { .frame(width: 1, height: 1) .hidden() } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if memberWithChat.wrapped.memberPending { + Button { + showAcceptMemberAlert(groupInfo, memberWithChat.wrapped) + } label: { + Label("Accept", systemImage: "checkmark") + } + .tint(theme.colors.primary) + } + + Button { + showRemoveMemberAlert(groupInfo, memberWithChat.wrapped) + } label: { + Label("Remove", systemImage: "trash") + } + .tint(.red) + } } } @@ -168,24 +186,6 @@ struct MemberSupportView: View { SupportChatUnreadIndicator(supportChat: supportChat) } } - // TODO [knocking] swipe actions are broken -// .swipeActions(edge: .trailing, allowsFullSwipe: true) { -// if member.memberPending { -// Button { -// showAcceptMemberAlert(groupInfo, member) -// } label: { -// Label("Accept", systemImage: "checkmark") -// } -// .tint(theme.colors.primary) -// } -// -// Button { -// showRemoveMemberAlert(groupInfo, member) -// } label: { -// Label("Remove", systemImage: "trash") -// } -// .tint(.red) -// } } private func memberStatus(_ member: GroupMember) -> LocalizedStringKey { From d193658cac2f16d73f6c5ea9a2ca67b3e7bf4d56 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 6 May 2025 14:55:54 +0000 Subject: [PATCH 19/72] ios: auto-open support chat for invitee in pending status (#5871) --- apps/ios/Shared/Views/Chat/ChatView.swift | 54 ++++++++++++++++++- .../Views/Chat/Group/GroupChatInfoView.swift | 6 ++- .../Views/Chat/Group/MemberSupportView.swift | 3 +- .../Views/Chat/Group/SecondaryChatView.swift | 7 ++- .../Shared/Views/ChatList/ChatListView.swift | 3 +- 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index d585d88791..4e26055903 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -25,6 +25,7 @@ struct ChatView: View { @ObservedObject var im: ItemsModel @State var mergedItems: BoxedValue @State var floatingButtonModel: FloatingButtonModel + var onSheet: Bool @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -56,11 +57,14 @@ struct ChatView: View { @State private var allowLoadMoreItems: Bool = false @State private var ignoreLoadingRequests: Int64? = nil @State private var animatedScrollingInProgress: Bool = false + @State private var showUserSupportChatSheet = false @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + var body: some View { if #available(iOS 16.0, *) { viewBody @@ -86,6 +90,9 @@ struct ChatView: View { ) } VStack(spacing: 0) { + if onSheet { + customUserSupportChatNavigationBar() + } ZStack(alignment: .bottomTrailing) { chatItemsList() if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { @@ -223,6 +230,13 @@ struct ChatView: View { } } } + if case let .group(groupInfo, nil) = chat.chatInfo, + groupInfo.membership.memberPending { + let secIM = ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) + secIM.loadOpenChat(chat.id) { + showUserSupportChatSheet = true + } + } } .onChange(of: chatModel.chatId) { cId in showChatInfoSheet = false @@ -336,6 +350,16 @@ struct ChatView: View { localAlias: groupInfo.localAlias ) } + .appSheet( + isPresented: $showUserSupportChatSheet, + onDismiss: { + if groupInfo.membership.memberPending { + chatModel.chatId = nil + } + } + ) { + userSupportChat(groupInfo) + } } else if case .local = cInfo { ChatInfoToolbar(chat: chat) } @@ -457,6 +481,33 @@ struct ChatView: View { } } + @ViewBuilder private func userSupportChat(_ groupInfo: GroupInfo) -> some View { + if let secondaryIM = chatModel.secondaryIM { + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats()), + im: secondaryIM, + onSheet: true + ) + } else { + EmptyView() + } + } + + private func customUserSupportChatNavigationBar() -> some View { + VStack(spacing: 0) { + HStack { + Text("Support") + .font(.headline) + .foregroundColor(theme.colors.onBackground) + } + .padding(.horizontal) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(.thinMaterial) + Divider() + } + } + func textChatToolbar(_ text: LocalizedStringKey) -> some View { HStack { Text(text).font(.headline) @@ -2605,7 +2656,8 @@ struct ChatView_Previews: PreviewProvider { chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im) + floatingButtonModel: FloatingButtonModel(im: im), + onSheet: false ) .environmentObject(chatModel) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index bdc315a918..1d578fcb41 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -557,7 +557,8 @@ struct GroupChatInfoView: View { if let secondaryIM = chatModel.secondaryIM { SecondaryChatView( chat: chat, - im: secondaryIM + im: secondaryIM, + onSheet: false ) } } label: { @@ -600,7 +601,8 @@ struct GroupChatInfoView: View { if let secondaryIM = chatModel.secondaryIM { SecondaryChatView( chat: chat, - im: secondaryIM + im: secondaryIM, + onSheet: false ) } } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index ea07532c22..88d6ab13e8 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -86,7 +86,8 @@ struct MemberSupportView: View { if let secondaryIM = chatModel.secondaryIM { SecondaryChatView( chat: chat, - im: secondaryIM + im: secondaryIM, + onSheet: false ) } } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift index ef36cc3a31..13b3252cf4 100644 --- a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -13,13 +13,15 @@ struct SecondaryChatView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat @ObservedObject var im: ItemsModel + var onSheet: Bool var body: some View { ChatView( chat: chat, im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im) + floatingButtonModel: FloatingButtonModel(im: im), + onSheet: onSheet ) .onDisappear { chatModel.secondaryIM = nil @@ -34,6 +36,7 @@ struct SecondaryChatView: View { chatItems: [], chatStats: ChatStats() ), - im: ItemsModel.shared + im: ItemsModel.shared, + onSheet: false ) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 59364c9498..46c8cc9ae0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -457,7 +457,8 @@ struct ChatListView: View { chat: chat, im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im) + floatingButtonModel: FloatingButtonModel(im: im), + onSheet: false ) } } From 3c36b0c692c8ec62e57b18a51fe1c924cca87a3b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 7 May 2025 11:25:23 +0400 Subject: [PATCH 20/72] ios: fix chat view closing for invitee after being accepted --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4e26055903..2dcac773eb 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -353,7 +353,7 @@ struct ChatView: View { .appSheet( isPresented: $showUserSupportChatSheet, onDismiss: { - if groupInfo.membership.memberPending { + if chat.chatInfo.groupInfo?.membership.memberPending ?? false { chatModel.chatId = nil } } From d0870591532cbf8d60709d8ce2f62cb3520c9d38 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 7 May 2025 07:37:17 +0000 Subject: [PATCH 21/72] ios: close support chat for invitee when accepted (#5873) --- apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/Model/SimpleXAPI.swift | 8 +++++++- apps/ios/Shared/Views/Chat/ChatView.swift | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 76b71f0117..d247ac9ebd 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -387,6 +387,7 @@ final class ChatModel: ObservableObject { // ItemsModel for secondary chat view (such as support scope chat), as opposed to ItemsModel.shared used for primary chat @Published var secondaryIM: ItemsModel? = nil + @Published var secondaryPendingInviteeChatOpened = false static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 969661fe6c..6e704daaa5 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2285,11 +2285,17 @@ func processReceivedMsg(_ res: ChatEvent) async { } } case let .userJoinedGroup(user, groupInfo): - // TODO [knocking] close support scope for this group if it's currently opened if active(user) { await MainActor.run { m.updateGroup(groupInfo) } + if m.chatId == groupInfo.id, + case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo { + await MainActor.run { + m.secondaryPendingInviteeChatOpened = false + m.secondaryIM = nil + } + } } case let .joinedGroupMember(user, groupInfo, member): if active(user) { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 2dcac773eb..e6df139569 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -234,6 +234,7 @@ struct ChatView: View { groupInfo.membership.memberPending { let secIM = ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) secIM.loadOpenChat(chat.id) { + chatModel.secondaryPendingInviteeChatOpened = true showUserSupportChatSheet = true } } @@ -268,6 +269,11 @@ struct ChatView: View { dismiss() } } + .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { opened in + if im.secondaryIMFilter != nil && !opened { + dismiss() + } + } .onChange(of: chatModel.openAroundItemId) { openAround in if let openAround { closeSearch() From 1ba863bedcd3652dfdf9eaa807b88ebc5799af03 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 7 May 2025 12:38:24 +0400 Subject: [PATCH 22/72] ios: close support chat when accepting/removing pending member --- .../ContextPendingMemberActionsView.swift | 15 ++++++++------- .../Views/Chat/Group/GroupChatInfoView.swift | 3 ++- .../Views/Chat/Group/MemberSupportView.swift | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index efb7cf1d96..5692bbd01b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -9,9 +9,9 @@ import SwiftUI import SimpleXChat -// TODO [knocking] go back (close secondary ChatView) on actions struct ContextPendingMemberActionsView: View { @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) var dismiss var groupInfo: GroupInfo var member: GroupMember @@ -24,7 +24,7 @@ struct ContextPendingMemberActionsView: View { .frame(maxWidth: .infinity) .contentShape(Rectangle()) .onTapGesture { - showRemoveMemberAlert(groupInfo, member) + showRemoveMemberAlert(groupInfo, member, dismiss: dismiss) } ZStack { @@ -34,7 +34,7 @@ struct ContextPendingMemberActionsView: View { .frame(maxWidth: .infinity) .contentShape(Rectangle()) .onTapGesture { - showAcceptMemberAlert(groupInfo, member) + showAcceptMemberAlert(groupInfo, member, dismiss: dismiss) } } .frame(minHeight: 54) @@ -43,7 +43,7 @@ struct ContextPendingMemberActionsView: View { } } -func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { +func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { showAlert( NSLocalizedString("Accept member", comment: "alert title"), message: NSLocalizedString("Member will join the group, accept member?", comment: "alert message"), @@ -52,14 +52,14 @@ func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { title: NSLocalizedString("Accept as member", comment: "alert action"), style: .default, handler: { _ in - acceptMember(groupInfo, member, .member) + acceptMember(groupInfo, member, .member, dismiss: dismiss) } ), UIAlertAction( title: NSLocalizedString("Accept as observer", comment: "alert action"), style: .default, handler: { _ in - acceptMember(groupInfo, member, .observer) + acceptMember(groupInfo, member, .observer, dismiss: dismiss) } ), UIAlertAction( @@ -70,12 +70,13 @@ func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { ) } -func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole) { +func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole, dismiss: DismissAction? = nil) { Task { do { let acceptedMember = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember) + dismiss?() } } catch let error { logger.error("apiAcceptMember error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 1d578fcb41..66865f39ba 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -781,7 +781,7 @@ struct GroupChatInfoView: View { } } -func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember) { +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { Task { do { let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) @@ -789,6 +789,7 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember) { updatedMembers.forEach { updatedMember in _ = ChatModel.shared.upsertGroupMember(groupInfo, updatedMember) } + dismiss?() } } catch let error { logger.error("apiRemoveMembers error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 88d6ab13e8..71d7efec92 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -252,11 +252,11 @@ struct MemberSupportView: View { } } -func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { +func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { showAlert( title: NSLocalizedString("Remove member?", comment: "alert title"), buttonTitle: "Remove", - buttonAction: { removeMember(groupInfo, member) }, + buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, cancelButton: true ) } From 6b71cd2ee95a376a7bb689a1d37c9176d76f7e55 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 7 May 2025 12:50:27 +0000 Subject: [PATCH 23/72] core: add index for reading scoped chats stats (#5876) --- .../Migrations/M20250403_group_scope.hs | 11 ++++++++ .../SQLite/Migrations/agent_query_plans.txt | 2 +- .../SQLite/Migrations/chat_query_plans.txt | 28 +++++++++---------- .../Store/SQLite/Migrations/chat_schema.sql | 8 ++++++ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs index b203d14d2f..313d9b757d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs @@ -28,11 +28,22 @@ CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( group_scope_group_member_id, item_ts ); + +CREATE INDEX idx_chat_items_group_scope_item_status ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_status, + item_ts +); |] down_m20250403_group_scope :: Query down_m20250403_group_scope = [sql| +DROP INDEX idx_chat_items_group_scope_item_status; + DROP INDEX idx_chat_items_group_scope_item_ts; DROP INDEX idx_chat_items_group_scope_group_member_id; 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 a10d3f3db7..13215dcb75 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1119,7 +1119,7 @@ Query: UPDATE rcv_messages SET user_ack = ? WHERE conn_id = ? AND internal_id = Plan: SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) -Query: UPDATE rcv_queues SET last_broker_ts = ? WHERE conn_id = ? AND rcv_queue_id = ? AND last_broker_ts < ? +Query: UPDATE rcv_queues SET last_broker_ts = ? WHERE conn_id = ? AND rcv_queue_id = ? AND (last_broker_ts IS NULL OR last_broker_ts < ?) Plan: SEARCH rcv_queues USING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_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 3b63f346d5..f4ba1c1e4e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1989,7 +1989,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -2048,7 +2048,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -2105,7 +2105,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -2219,7 +2219,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -2276,7 +2276,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -2390,7 +2390,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -2444,7 +2444,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -2552,7 +2552,7 @@ SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN @@ -5640,7 +5640,7 @@ SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_ Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NOT NULL AND item_status = ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag>?) Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 Plan: @@ -5656,7 +5656,7 @@ SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? Plan: @@ -5684,7 +5684,7 @@ SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1 Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups (user_id=? AND group_id=? AND item_status=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND group_member_id = ? LIMIT 1 Plan: diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 46d14ad6a8..e1cefaa1d1 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1038,3 +1038,11 @@ CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( group_scope_group_member_id, item_ts ); +CREATE INDEX idx_chat_items_group_scope_item_status ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_status, + item_ts +); From e3957ba15670b68e703e22f71594bfc484592241 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 7 May 2025 14:50:17 +0000 Subject: [PATCH 24/72] core: postgres migration for knocking; fix previous migration (#5877) --- simplex-chat.cabal | 1 + src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20250402_short_links.hs | 2 + .../Migrations/M20250403_group_scope.hs | 62 +++++++++++++++++++ .../Migrations/M20250403_group_scope.hs | 1 - 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index dac210e7d1..7cc689cace 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -104,6 +104,7 @@ library Simplex.Chat.Store.Postgres.Migrations Simplex.Chat.Store.Postgres.Migrations.M20241220_initial Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links + Simplex.Chat.Store.Postgres.Migrations.M20250403_group_scope else exposed-modules: Simplex.Chat.Archive diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index dc7202edc8..d18dba6b7c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -6,12 +6,14 @@ import Data.List (sortOn) import Data.Text (Text) import Simplex.Chat.Store.Postgres.Migrations.M20241220_initial import Simplex.Chat.Store.Postgres.Migrations.M20250402_short_links +import Simplex.Chat.Store.Postgres.Migrations.M20250403_group_scope import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = [ ("20241220_initial", m20241220_initial, Nothing), - ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links) + ("20250402_short_links", m20250402_short_links, Just down_m20250402_short_links), + ("20250403_group_scope", m20250403_group_scope, Just down_m20250403_group_scope) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs index de4f699377..4b3b7e9640 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250402_short_links.hs @@ -12,6 +12,7 @@ m20250402_short_links = [r| ALTER TABLE user_contact_links ADD COLUMN short_link_contact BYTEA; ALTER TABLE connections ADD COLUMN short_link_inv BYTEA; +ALTER TABLE connections ADD COLUMN via_short_link_contact BYTEA; |] down_m20250402_short_links :: Text @@ -20,4 +21,5 @@ down_m20250402_short_links = [r| ALTER TABLE user_contact_links DROP COLUMN short_link_contact; ALTER TABLE connections DROP COLUMN short_link_inv; +ALTER TABLE connections DROP COLUMN via_short_link_contact; |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs new file mode 100644 index 0000000000..0dcd47527e --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250403_group_scope where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250403_group_scope :: Text +m20250403_group_scope = + T.pack + [r| +ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; + +ALTER TABLE group_members ADD COLUMN support_chat_ts TIMESTAMPTZ; +ALTER TABLE group_members ADD COLUMN support_chat_items_unread BIGINT NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention BIGINT NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_items_mentions BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; +ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +CREATE INDEX idx_chat_items_group_scope_group_member_id ON chat_items(group_scope_group_member_id); + +CREATE INDEX idx_chat_items_group_scope_item_ts ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_ts +); + +CREATE INDEX idx_chat_items_group_scope_item_status ON chat_items( + user_id, + group_id, + group_scope_tag, + group_scope_group_member_id, + item_status, + item_ts +); +|] + +down_m20250403_group_scope :: Text +down_m20250403_group_scope = + T.pack + [r| +DROP INDEX idx_chat_items_group_scope_item_status; + +DROP INDEX idx_chat_items_group_scope_item_ts; + +DROP INDEX idx_chat_items_group_scope_group_member_id; + +ALTER TABLE chat_items DROP COLUMN group_scope_tag; +ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; + +ALTER TABLE group_members DROP COLUMN support_chat_ts; +ALTER TABLE group_members DROP COLUMN support_chat_items_unread; +ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; +ALTER TABLE group_members DROP COLUMN support_chat_items_mentions; + +ALTER TABLE group_profiles DROP COLUMN member_admission; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs index 313d9b757d..d17e969866 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs @@ -5,7 +5,6 @@ module Simplex.Chat.Store.SQLite.Migrations.M20250403_group_scope where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- TODO [knocking] review indexes (drop idx_chat_items_groups_item_ts?) m20250403_group_scope :: Query m20250403_group_scope = [sql| From f5926e8b8437de0a3cb558928c7149015b6f2b5d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 8 May 2025 08:22:46 +0000 Subject: [PATCH 25/72] core, ui: update chat ts and pop chat for group on messages for secondary chat scope (#5878) --- apps/ios/Shared/Model/ChatModel.swift | 25 ++++--- .../chat/simplex/common/model/ChatModel.kt | 36 +++++----- src/Simplex/Chat/Store/Messages.hs | 66 +++++++++---------- 3 files changed, 68 insertions(+), 59 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d247ac9ebd..4c8fe12c60 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -564,16 +564,16 @@ final class ChatModel: ObservableObject { // } func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + // mark chat non deleted + if case let .direct(contact) = cInfo, contact.chatDeleted { + var updatedContact = contact + updatedContact.chatDeleted = false + updateContact(updatedContact) + } // update chat list - if cInfo.groupChatScope() == nil { - // mark chat non deleted - if case let .direct(contact) = cInfo, contact.chatDeleted { - var updatedContact = contact - updatedContact.chatDeleted = false - updateContact(updatedContact) - } + if let i = getChatIndex(cInfo.id) { // update preview - if let i = getChatIndex(cInfo.id) { + if cInfo.groupChatScope() == nil { chats[i].chatItems = switch cInfo { case .group: if let currentPreviewItem = chats[i].chatItems.first { @@ -591,9 +591,14 @@ final class ChatModel: ObservableObject { if case .rcvNew = cItem.meta.itemStatus { unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) } - popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) - } else { + } + // pop chat + popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) + } else { + if cInfo.groupChatScope() == nil { addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + } else { + addChat(Chat(chatInfo: cInfo, chatItems: [])) } } // add to current scope 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 3df8b31d2f..3dbae3dd01 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 @@ -429,18 +429,18 @@ object ChatModel { } suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + // mark chat non deleted + if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { + val updatedContact = cInfo.contact.copy(chatDeleted = false) + updateContact(rhId, updatedContact) + } // update chat list - if (cInfo.groupChatScope() == null) { - // mark chat non deleted - if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { - val updatedContact = cInfo.contact.copy(chatDeleted = false) - updateContact(rhId, updatedContact) - } + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] // update preview - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] + if (cInfo.groupChatScope() == null) { val newPreviewItem = when (cInfo) { is ChatInfo.Group -> { val currentPreviewItem = chat.chatItems.firstOrNull() @@ -468,14 +468,18 @@ object ChatModel { chat.chatStats ) updateChatTagReadInPrimaryContext(chats[i], wasUnread) - - if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chats[i], 0) - } else { - popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) - } + } + // pop chat + if (appPlatform.isDesktop && cItem.chatDir.sent) { + reorderChat(chats[i], 0) } else { + popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) + } + } else { + if (cInfo.groupChatScope() == null) { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = emptyList())) } } // add to current scope diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d0fb2bfa35..39ccb698fa 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -376,43 +376,43 @@ updateChatTsStats db User {userId} chatDirection chatTs chatStats_ = case toChat db "UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ?" (chatTs, userId, contactId) - GroupChat GroupInfo {groupId} Nothing -> + GroupChat GroupInfo {groupId, membership} scopeInfo -> do DB.execute db "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" (chatTs, userId, groupId) - GroupChat GroupInfo {membership} (Just GCSIMemberSupport {groupMember_}) -> do - let gmId = groupMemberId' $ fromMaybe membership groupMember_ - case chatStats_ of - Nothing -> - DB.execute - db - "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" - (chatTs, gmId) - Just (unread, MAInc unanswered, mentions) -> - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = support_chat_items_member_attention + ?, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - |] - (chatTs, unread, unanswered, mentions, gmId) - Just (unread, MAReset, mentions) -> - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = 0, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - |] - (chatTs, unread, mentions, gmId) + forM_ scopeInfo $ \GCSIMemberSupport {groupMember_} -> do + let gmId = groupMemberId' $ fromMaybe membership groupMember_ + case chatStats_ of + Nothing -> + DB.execute + db + "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" + (chatTs, gmId) + Just (unread, MAInc unanswered, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, unanswered, mentions, gmId) + Just (unread, MAReset, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, mentions, gmId) LocalChat NoteFolder {noteFolderId} -> DB.execute db From 2a9df3e10b7123a23f424ecd86079563df815cdc Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 8 May 2025 13:44:55 +0000 Subject: [PATCH 26/72] ui: knocking UI improvements (texts, icons, layout, chat view shortcut split toolbar, set admission on creating group) (#5879) --- apps/ios/Shared/Views/Chat/ChatView.swift | 4 +- .../Chat/Group/AddGroupMembersView.swift | 6 + .../Views/Chat/Group/GroupChatInfoView.swift | 44 ++++--- .../Chat/Group/GroupPreferencesView.swift | 120 +++++++++--------- .../Chat/Group/MemberAdmissionView.swift | 4 +- .../Views/Chat/Group/MemberSupportView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 5 + .../simplex/common/views/chat/ChatView.kt | 107 +++++++++++----- .../views/chat/group/AddGroupMembersView.kt | 15 +++ .../views/chat/group/GroupChatInfoView.kt | 37 +++--- .../commonMain/resources/MR/base/strings.xml | 17 +-- 11 files changed, 221 insertions(+), 140 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index e6df139569..3736e269a8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -455,7 +455,7 @@ struct ChatView: View { if let groupMember = groupMember_ { MemberSupportChatToolbar(groupMember: groupMember) } else { - textChatToolbar("Support") + textChatToolbar("Chat with admins") } } case let .msgContentTagContext(contentTag): @@ -502,7 +502,7 @@ struct ChatView: View { private func customUserSupportChatNavigationBar() -> some View { VStack(spacing: 0) { HStack { - Text("Support") + Text("Chat with admins") .font(.headline) .foregroundColor(theme.colors.onBackground) } diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 66fe67a29e..813a2d3d73 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -78,6 +78,12 @@ struct AddGroupMembersViewCommon: View { let count = selectedContacts.count Section { if creatingGroup { + MemberAdmissionButton( + groupInfo: $groupInfo, + admission: groupInfo.groupProfile.memberAdmission_, + currentAdmission: groupInfo.groupProfile.memberAdmission_, + creatingGroup: true + ) GroupPreferencesButton( groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 66865f39ba..c84124b593 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -89,6 +89,18 @@ struct GroupChatInfoView: View { .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) Section { + if groupInfo.canAddMembers && groupInfo.businessChat == nil { + groupLinkButton() + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink( + chat: chat, + im: ItemsModel(secondaryIMFilter: .msgContentTagContext(contentTag: .report)) + ) + } if groupInfo.membership.supportChat != nil { let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) UserSupportChatNavLink( @@ -96,15 +108,8 @@ struct GroupChatInfoView: View { im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) ) } - if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { - memberSupportButton() - } - if groupInfo.canModerate { - GroupReportsChatNavLink( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: [], chatStats: ChatStats()), - im: ItemsModel(secondaryIMFilter: .msgContentTagContext(contentTag: .report)) - ) - } + } header: { + Text("") } Section { @@ -115,8 +120,6 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - } header: { - Text("") } footer: { let label: LocalizedStringKey = ( groupInfo.businessChat == nil @@ -145,9 +148,6 @@ struct GroupChatInfoView: View { Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { if groupInfo.canAddMembers { - if groupInfo.businessChat == nil { - groupLinkButton() - } if (chat.chatInfo.incognito) { Label("Invite members", systemImage: "plus") .foregroundColor(Color(uiColor: .tertiaryLabel)) @@ -550,7 +550,7 @@ struct GroupChatInfoView: View { userSupportChatNavLinkActive = true } } label: { - Label("Support chat", systemImage: "flag") + Label("Chat with admins", systemImage: "flag") } NavigationLink(isActive: $userSupportChatNavLinkActive) { @@ -573,16 +573,20 @@ struct GroupChatInfoView: View { private func memberSupportButton() -> some View { NavigationLink { MemberSupportView(groupInfo: groupInfo) - .navigationBarTitle("Member support") + .navigationBarTitle("Chats with members") .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.large) } label: { - Label("Member support", systemImage: "flag") + Label( + "Chats with members", + systemImage: chat.chatStats.supportChatsUnreadCount > 0 ? "flag.fill" : "flag" + ) } } struct GroupReportsChatNavLink: View { @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme @State private var groupReportsChatNavLinkActive = false @ObservedObject var chat: Chat var im: ItemsModel @@ -594,7 +598,11 @@ struct GroupChatInfoView: View { groupReportsChatNavLinkActive = true } } label: { - Label("Member reports", systemImage: "flag") + Label( + "Member reports", + systemImage: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag" + ) + .foregroundColor(chat.chatStats.reportsCount > 0 ? .red : theme.colors.primary) } NavigationLink(isActive: $groupReportsChatNavLinkActive) { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 3ad4de639d..55b1dc6d2e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -33,8 +33,8 @@ struct GroupPreferencesView: View { Section { MemberAdmissionButton( groupInfo: $groupInfo, - admission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(), - currentAdmission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(), + admission: groupInfo.groupProfile.memberAdmission_, + currentAdmission: groupInfo.groupProfile.memberAdmission_, creatingGroup: creatingGroup ) } @@ -85,62 +85,6 @@ struct GroupPreferencesView: View { } } - struct MemberAdmissionButton: View { - @Binding var groupInfo: GroupInfo - @State var admission: GroupMemberAdmission - @State var currentAdmission: GroupMemberAdmission - var creatingGroup: Bool = false - - var body: some View { - NavigationLink { - MemberAdmissionView( - groupInfo: $groupInfo, - admission: $admission, - currentAdmission: currentAdmission, - creatingGroup: creatingGroup, - saveAdmission: saveAdmission - ) - .navigationBarTitle("Member admission") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - .onDisappear { - let saveText = NSLocalizedString( - creatingGroup ? "Save" : "Save and notify group members", - comment: "alert button" - ) - - if groupInfo.groupProfile.memberAdmission != admission { - showAlert( - title: NSLocalizedString("Save admission settings?", comment: "alert title"), - buttonTitle: saveText, - buttonAction: { saveAdmission() }, - cancelButton: true - ) - } - } - } label: { - Label("Member admission", systemImage: "switch.2") - } - } - - private func saveAdmission() { - Task { - do { - var gp = groupInfo.groupProfile - gp.memberAdmission = admission - let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) - await MainActor.run { - groupInfo = gInfo - ChatModel.shared.updateGroup(gInfo) - currentAdmission = admission - } - } catch { - logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))") - } - } - } - } - private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary @@ -204,6 +148,66 @@ struct GroupPreferencesView: View { } } +struct MemberAdmissionButton: View { + @Binding var groupInfo: GroupInfo + @State var admission: GroupMemberAdmission + @State var currentAdmission: GroupMemberAdmission + var creatingGroup: Bool = false + + var body: some View { + NavigationLink { + MemberAdmissionView( + groupInfo: $groupInfo, + admission: $admission, + currentAdmission: currentAdmission, + creatingGroup: creatingGroup, + saveAdmission: saveAdmission + ) + .navigationBarTitle("Member admission") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + .onDisappear { + let saveText = NSLocalizedString( + creatingGroup ? "Save" : "Save and notify group members", + comment: "alert button" + ) + + if groupInfo.groupProfile.memberAdmission_ != admission { + showAlert( + title: NSLocalizedString("Save admission settings?", comment: "alert title"), + buttonTitle: saveText, + buttonAction: { saveAdmission() }, + cancelButton: true + ) + } + } + } label: { + if creatingGroup { + Text("Set member admission") + } else { + Label("Member admission", systemImage: "switch.2") + } + } + } + + private func saveAdmission() { + Task { + do { + var gp = groupInfo.groupProfile + gp.memberAdmission = admission + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + currentAdmission = admission + } + } catch { + logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))") + } + } + } +} + struct GroupPreferencesView_Previews: PreviewProvider { static var previews: some View { GroupPreferencesView( diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift index dadf13d1a0..e1db2c35ad 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift @@ -30,8 +30,8 @@ struct MemberAdmissionView: View { VStack { List { admissionSection( - NSLocalizedString("Review", comment: "admission stage"), - NSLocalizedString("Review new members before admitting to group.", comment: "admission stage description"), + NSLocalizedString("Review members", comment: "admission stage"), + NSLocalizedString("Review members before admitting (\"knocking\").", comment: "admission stage description"), $admission.review ) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 71d7efec92..587647edea 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -44,7 +44,7 @@ struct MemberSupportView: View { : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } if membersWithChats.isEmpty { - Text("No support chats") + Text("No chats with members") .foregroundColor(.secondary) } else { List { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 72ef033e6b..8501d55bab 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2101,6 +2101,11 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } + public var memberAdmission_: GroupMemberAdmission { + get { self.memberAdmission ?? GroupMemberAdmission() } + set { memberAdmission = newValue } + } + public static let sampleData = GroupProfile( displayName: "team", fullName: "My Team" 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 7af2317123..84d811d1a9 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 @@ -342,7 +342,7 @@ fun ChatView( } } }, - showReportsOrSupportChats = { + showReports = { val info = activeChatInfo.value ?: return@ChatLayout if (ModalManager.end.hasModalsOpen()) { ModalManager.end.closeModals() @@ -350,9 +350,18 @@ fun ChatView( } hideKeyboard(view) scope.launch { - if (reportsCount > 0) { - showGroupReportsView(staleChatId, scrollToItemId, info) - } else if (info is ChatInfo.Group && info.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + showGroupReportsView(staleChatId, scrollToItemId, info) + } + }, + showSupportChats = { + val info = activeChatInfo.value ?: return@ChatLayout + if (ModalManager.end.hasModalsOpen()) { + ModalManager.end.closeModals() + return@ChatLayout + } + hideKeyboard(view) + scope.launch { + if (info is ChatInfo.Group && info.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ModalManager.end.showCustomModal { close -> MemberSupportView( chatRh, @@ -733,7 +742,8 @@ fun ChatLayout( selectedChatItems: MutableState?>, back: () -> Unit, info: () -> Unit, - showReportsOrSupportChats: () -> Unit, + showReports: () -> Unit, + showSupportChats: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -887,7 +897,7 @@ fun ChatLayout( val supportChatsUnreadCount = supportChatsUnreadCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { if (chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0)) { - SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = true, showReportsOrSupportChats) + SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = true, showReports, showSupportChats) } else { StatusBarBackground() } @@ -944,7 +954,7 @@ fun ChatLayout( } } if ((reportsCount > 0 || supportChatsUnreadCount > 0) && (!oneHandUI.value || !chatBottomBar.value)) { - SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = false, showReportsOrSupportChats) + SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = false, showReports, showSupportChats) } } } @@ -1180,37 +1190,64 @@ private fun SupportChatsCountToolbar( reportsCount: Int, supportChatsUnreadCount: Int, withStatusBar: Boolean, - showReportsOrSupportChats: () -> Unit + showReports: () -> Unit, + showSupportChats: () -> Unit ) { Box { val statusBarPadding = if (withStatusBar) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else 0.dp Row( Modifier - .fillMaxWidth() - .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) - .background(MaterialTheme.colors.background) - .clickable(onClick = showReportsOrSupportChats) - .padding(top = statusBarPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, ) { - val iconColor = if (reportsCount == 0) MaterialTheme.colors.primary else MaterialTheme.colors.error - Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = iconColor) - Spacer(Modifier.width(4.dp)) - Text( - if (supportChatsUnreadCount == 0) { - if (reportsCount == 1) { - stringResource(MR.strings.group_reports_active_one) - } else { - stringResource(MR.strings.group_reports_active).format(reportsCount) - } - } else if (reportsCount == 0) { - stringResource(MR.strings.group_new_support_messages).format(supportChatsUnreadCount) - } else { - String.format(generalGetString(MR.strings.group_reports_active_new_support_messages), reportsCount, supportChatsUnreadCount) - }, - style = MaterialTheme.typography.button - ) + if (reportsCount > 0) { + Row( + Modifier + .fillMaxWidth() + .weight(1F) + .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) + .background(MaterialTheme.colors.background) + .clickable(onClick = showReports) + .padding(top = statusBarPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) + Spacer(Modifier.width(4.dp)) + Text( + if (reportsCount == 1) { + stringResource(MR.strings.group_reports_active_one) + } else { + stringResource(MR.strings.group_reports_active).format(reportsCount) + }, + style = MaterialTheme.typography.button + ) + } + } + + if (supportChatsUnreadCount > 0) { + Row( + Modifier + .fillMaxWidth() + .weight(1F) + .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) + .background(MaterialTheme.colors.background) + .clickable(onClick = showSupportChats) + .padding(top = statusBarPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(4.dp)) + Text( + if (appPlatform.isAndroid) + stringResource(MR.strings.group_new_support_messages_short).format(supportChatsUnreadCount) + else + stringResource(MR.strings.group_new_support_messages).format(supportChatsUnreadCount), + style = MaterialTheme.typography.button + ) + } + } } Divider(Modifier.align(Alignment.BottomStart)) } @@ -3027,7 +3064,8 @@ fun PreviewChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, - showReportsOrSupportChats = {}, + showReports = {}, + showSupportChats = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, @@ -3105,7 +3143,8 @@ fun PreviewGroupChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, - showReportsOrSupportChats = {}, + showReports = {}, + showSupportChats = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, 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 369a498c25..48c1ba5200 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 @@ -55,6 +55,16 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea GroupPreferencesView(chatModel, rhId, groupInfo.id, close) } }, + openMemberAdmission = { + ModalManager.end.showCustomModal { close -> + MemberAdmissionView( + chat.simplex.common.platform.chatModel, + rhId, + groupInfo.id, + close + ) + } + }, inviteMembers = { allowModifyMembers = false withLongRunningApi(slow = 120_000) { @@ -110,6 +120,7 @@ fun AddGroupMembersLayout( allowModifyMembers: Boolean, searchText: MutableState, openPreferences: () -> Unit, + openMemberAdmission: () -> Unit, inviteMembers: () -> Unit, clearSelection: () -> Unit, addContact: (Long) -> Unit, @@ -165,6 +176,9 @@ fun AddGroupMembersLayout( } else { SectionView { if (creatingGroup) { + SectionItemView(openMemberAdmission) { + Text(stringResource(MR.strings.set_member_admission)) + } SectionItemView(openPreferences) { Text(stringResource(MR.strings.set_group_preferences)) } @@ -376,6 +390,7 @@ fun PreviewAddGroupMembersLayout() { allowModifyMembers = true, searchText = remember { mutableStateOf(TextFieldValue("")) }, openPreferences = {}, + openMemberAdmission = {}, inviteMembers = {}, clearSelection = {}, addContact = {}, 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 beb7539171..9a27dd150b 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 @@ -463,22 +463,30 @@ fun ModalData.GroupChatInfoLayout( var anyTopSectionRowShow = false SectionView { - if (groupInfo.membership.supportChat != null) { + if (groupInfo.canAddMembers && groupInfo.businessChat == null) { anyTopSectionRowShow = true - UserSupportChatButton(groupInfo, scrollToItemId) + if (groupLink == null) { + CreateGroupLinkButton(manageGroupLink) + } else { + GroupLinkButton(manageGroupLink) + } } if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { anyTopSectionRowShow = true - MemberSupportButton(openMemberSupport) + MemberSupportButton(chat, openMemberSupport) } if (groupInfo.canModerate) { anyTopSectionRowShow = true - GroupReportsButton { + GroupReportsButton(chat) { scope.launch { showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) } } } + if (groupInfo.membership.supportChat != null) { + anyTopSectionRowShow = true + UserSupportChatButton(groupInfo, scrollToItemId) + } } if (anyTopSectionRowShow) { SectionDividerSpaced(maxBottomPadding = false) @@ -520,13 +528,6 @@ fun ModalData.GroupChatInfoLayout( SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { if (groupInfo.canAddMembers) { - if (groupInfo.businessChat == null) { - if (groupLink == null) { - CreateGroupLinkButton(manageGroupLink) - } else { - GroupLinkButton(manageGroupLink) - } - } val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary val addMembersTitleId = when (groupInfo.businessChat?.chatType) { @@ -738,11 +739,12 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { } @Composable -private fun MemberSupportButton(onClick: () -> Unit) { +private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { SettingsActionItem( - painterResource(MR.images.ic_flag), + painterResource(if (chat.chatStats.supportChatsUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.member_support), - click = onClick + click = onClick, + iconColor = (if (chat.chatStats.supportChatsUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) ) } @@ -756,11 +758,12 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) } @Composable -private fun GroupReportsButton(onClick: () -> Unit) { +private fun GroupReportsButton(chat: Chat, onClick: () -> Unit) { SettingsActionItem( - painterResource(MR.images.ic_flag), + painterResource(if (chat.chatStats.reportsCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.group_reports_member_reports), - click = onClick + click = onClick, + iconColor = (if (chat.chatStats.reportsCount > 0) Color.Red else MaterialTheme.colors.secondary) ) } 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 1f4115e927..4cc727ba4f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -466,7 +466,7 @@ %d reports Member reports %d new support messages - %1$d reports, %2$d new support messages + %d messages Share message… @@ -512,7 +512,7 @@ Report content: only group moderators will see it. Report other: only group moderators will see it. Report sent to moderators - You can view your reports in Support Chat. + You can view your reports in Chat with admins. Image @@ -1767,7 +1767,7 @@ Remove member? Remove members? Remove member - Support chat + Chat with admins Send direct message Member will be removed from group - this cannot be undone! Members will be removed from group - this cannot be undone! @@ -2044,6 +2044,7 @@ Contact preferences Group preferences Set group preferences + Set member admission Your preferences Disappearing messages Direct messages @@ -2163,17 +2164,17 @@ Member admission - Review - Review new members before admitting to group. + Review members + Review members before admitting ("knocking"). off all - Member support - No support chats + Chats with members + No chats with members - Support + Chat with admins Remove Accept Accept member From 79991e84c970e3249486c06fe21494f92190b371 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 9 May 2025 15:36:06 +0000 Subject: [PATCH 27/72] core: member requires attention counter (#5881) --- apps/ios/Shared/Model/AppAPITypes.swift | 3 + apps/ios/Shared/Model/ChatModel.swift | 31 +- apps/ios/Shared/Model/SimpleXAPI.swift | 17 +- .../Shared/Views/Chat/ChatInfoToolbar.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 6 - .../ContextPendingMemberActionsView.swift | 3 +- .../Views/Chat/Group/GroupChatInfoView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 6 +- .../chat/simplex/common/model/ChatModel.kt | 38 +-- .../chat/simplex/common/model/SimpleXAPI.kt | 17 +- .../simplex/common/views/chat/ChatView.kt | 42 +-- .../ComposeContextPendingMemberActionsView.kt | 9 +- .../views/chat/group/GroupChatInfoView.kt | 4 +- .../common/views/chatlist/ChatPreviewView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 5 +- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 30 +- src/Simplex/Chat/Library/Internal.hs | 29 +- src/Simplex/Chat/Library/Subscriber.hs | 164 ++++++----- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Store/Connections.hs | 3 +- src/Simplex/Chat/Store/Groups.hs | 48 +++- src/Simplex/Chat/Store/Messages.hs | 247 +++++++++------- .../Migrations/M20250403_group_scope.hs | 4 + .../Migrations/M20250403_group_scope.hs | 4 + .../SQLite/Migrations/chat_query_plans.txt | 267 ++++++------------ .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store/Shared.hs | 9 +- src/Simplex/Chat/Types.hs | 10 +- src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Direct.hs | 4 +- tests/ChatTests/Groups.hs | 8 +- 33 files changed, 530 insertions(+), 494 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 686472003f..843b7e6133 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -727,6 +727,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case contactDeleted(user: UserRef, contact: Contact) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) + case itemsReadForChat(user: UserRef, chatInfo: ChatInfo) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -765,6 +766,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .contactDeleted: "contactDeleted" case .contactConnectionDeleted: "contactConnectionDeleted" case .groupDeletedUser: "groupDeletedUser" + case .itemsReadForChat: "itemsReadForChat" case .chatCleared: "chatCleared" case .userProfileNoChange: "userProfileNoChange" case .userProfileUpdated: "userProfileUpdated" @@ -797,6 +799,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .itemsReadForChat(u, chatInfo): return withUser(u, String(describing: chatInfo)) case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4c8fe12c60..3588ec8db4 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -564,6 +564,8 @@ final class ChatModel: ObservableObject { // } func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + // updates membersRequireAttention + updateChatInfo(cInfo) // mark chat non deleted if case let .direct(contact) = cInfo, contact.chatDeleted { var updatedContact = contact @@ -1034,23 +1036,6 @@ final class ChatModel: ObservableObject { return unread } - func increaseGroupSupportChatsUnreadCounter(_ chatId: ChatId) { - changeGroupSupportChatsUnreadCounter(chatId, 1) - } - - func decreaseGroupSupportChatsUnreadCounter(_ chatId: ChatId, by: Int = 1) { - changeGroupSupportChatsUnreadCounter(chatId, -by) - } - - private func changeGroupSupportChatsUnreadCounter(_ chatId: ChatId, _ by: Int = 0) { - if by == 0 { return } - - if let i = getChatIndex(chatId) { - let chat = chats[i] - chat.chatStats.supportChatsUnreadCount = max(0, chat.chatStats.supportChatsUnreadCount + by) - } - } - func increaseGroupReportsCounter(_ chatId: ChatId) { changeGroupReportsCounter(chatId, 1) } @@ -1296,6 +1281,18 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } + var supportUnreadCount: Int { + switch chatInfo { + case let .group(groupInfo, _): + if groupInfo.canModerate { + return groupInfo.membersRequireAttention + } else { + return groupInfo.membership.supportChat?.unread ?? 0 + } + default: return 0 + } + } + public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6e704daaa5..032ed46c8b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1252,8 +1252,10 @@ func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) } -func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws { - try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds)) +func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo { + let r: ChatResponse1 = try await chatSendCmd(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds)) + if case let .itemsReadForChat(_, updatedChatInfo) = r { return updatedChatInfo } + throw r.unexpected } func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { @@ -1587,8 +1589,9 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { - try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) + let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) await MainActor.run { + ChatModel.shared.updateChatInfo(updatedChatInfo) ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead) } } catch { @@ -1637,9 +1640,9 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } -func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { +func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) { let r: ChatResponse2 = try await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole)) - if case let .memberAccepted(_, _, member) = r { return member } + if case let .memberAccepted(_, groupInfo, member) = r { return (groupInfo, member) } throw r.unexpected } @@ -2131,9 +2134,6 @@ func processReceivedMsg(_ res: ChatEvent) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } - if cInfo.groupChatScope() != nil && cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { - m.increaseGroupSupportChatsUnreadCounter(cInfo.id) - } } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } @@ -2252,6 +2252,7 @@ func processReceivedMsg(_ res: ChatEvent) async { if active(user) { await MainActor.run { _ = m.upsertGroupMember(groupInfo, member) + m.updateGroup(groupInfo) } } case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 9a2032ea4c..b60842a4a0 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -35,7 +35,7 @@ struct ChatInfoToolbar: View { .frame(width: 14, height: 14) .symbolRenderingMode(.palette) .foregroundStyle(.white, .red) - } else if chat.chatStats.supportChatsUnreadCount > 0 { + } else if chat.supportUnreadCount > 0 { Image(systemName: "flag.circle.fill") .resizable() .scaledToFit() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 3736e269a8..0eafb89070 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1339,17 +1339,11 @@ struct ChatView: View { if !itemIds.isEmpty { waitToMarkRead { await apiMarkChatItemsRead(im, chat.chatInfo, itemIds, mentionsRead: unreadMentions) - if (im.secondaryIMFilter != nil) { - m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id, by: itemIds.count ) - } } } } else if chatItem.isRcvNew { waitToMarkRead { await apiMarkChatItemsRead(im, chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) - if (im.secondaryIMFilter != nil) { - m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id) - } } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index 5692bbd01b..5f78581360 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -73,9 +73,10 @@ func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismis func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole, dismiss: DismissAction? = nil) { Task { do { - let acceptedMember = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) + let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember) + ChatModel.shared.updateGroup(gInfo) dismiss?() } } catch let error { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index c84124b593..56bbed7357 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -579,7 +579,7 @@ struct GroupChatInfoView: View { } label: { Label( "Chats with members", - systemImage: chat.chatStats.supportChatsUnreadCount > 0 ? "flag.fill" : "flag" + systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag" ) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 40736e3887..64a444e739 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -445,7 +445,7 @@ struct ChatPreviewView: View { ProgressView() } else if chat.chatStats.reportsCount > 0 { groupReportsIcon(size: size * 0.8) - } else if chat.chatStats.supportChatsUnreadCount > 0 { + } else if chat.supportUnreadCount > 0 { GroupSupportUnreadIcon(size: size * 0.8) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 8501d55bab..3c854b03ca 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1610,14 +1610,12 @@ public struct ChatStats: Decodable, Hashable { unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, - supportChatsUnreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false ) { self.unreadCount = unreadCount self.unreadMentions = unreadMentions self.reportsCount = reportsCount - self.supportChatsUnreadCount = supportChatsUnreadCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } @@ -1626,8 +1624,6 @@ public struct ChatStats: Decodable, Hashable { public var unreadMentions: Int = 0 // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 - // actual only via getChats() and getChat(.initial), otherwise, zero - public var supportChatsUnreadCount: Int = 0 public var minUnreadItemId: Int64 = 0 // actual only via getChats(), otherwise, false public var unreadChat: Bool = false @@ -2029,6 +2025,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { var updatedAt: Date var chatTs: Date? public var uiThemes: ThemeModeOverrides? + public var membersRequireAttention: Int public var id: ChatId { get { "#\(groupId)" } } public var apiId: Int64 { get { groupId } } @@ -2066,6 +2063,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, + membersRequireAttention: 0, chatTags: [], localAlias: "" ) 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 3dbae3dd01..fc8eb66eb1 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 @@ -429,6 +429,8 @@ object ChatModel { } suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + // updates membersRequireAttention + updateChatInfo(rhId, cInfo) // mark chat non deleted if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { val updatedContact = cInfo.contact.copy(chatDeleted = false) @@ -903,28 +905,6 @@ object ChatModel { } } - fun increaseGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId) { - changeGroupSupportChatsUnreadCounter(rhId, chatId, 1) - } - - fun decreaseGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId, by: Int = 1) { - changeGroupSupportChatsUnreadCounter(rhId, chatId, -by) - } - - private fun changeGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { - if (by == 0) return - - val i = getChatIndex(rhId, chatId) - if (i >= 0) { - val chat = chats.value[i] - chats[i] = chat.copy( - chatStats = chat.chatStats.copy( - supportChatsUnreadCount = (chat.chatStats.supportChatsUnreadCount + by).coerceAtLeast(0), - ) - ) - } - } - fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { changeGroupReportsCounter(rhId, chatId, 1) } @@ -1329,6 +1309,16 @@ data class Chat( val id: String get() = chatInfo.id + val supportUnreadCount: Int get() = when (chatInfo) { + is ChatInfo.Group -> + if (chatInfo.groupInfo.canModerate) { + chatInfo.groupInfo.membersRequireAttention + } else { + chatInfo.groupInfo.membership.supportChat?.unread ?: 0 + } + else -> 0 + } + fun groupFeatureEnabled(feature: GroupFeature): Boolean = if (chatInfo is ChatInfo.Group) { chatInfo.groupInfo.groupFeatureEnabled(feature) @@ -1342,8 +1332,6 @@ data class Chat( val unreadMentions: Int = 0, // actual only via getChats() and getChat(.initial), otherwise, zero val reportsCount: Int = 0, - // actual only via getChats() and getChat(.initial), otherwise, zero - val supportChatsUnreadCount: Int = 0, val minUnreadItemId: Long = 0, // actual only via getChats(), otherwise, false val unreadChat: Boolean = false @@ -1853,6 +1841,7 @@ data class GroupInfo ( override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, + val membersRequireAttention: Int, val chatTags: List, val chatItemTTL: Long?, override val localAlias: String, @@ -1915,6 +1904,7 @@ data class GroupInfo ( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, + membersRequireAttention = 0, chatTags = emptyList(), localAlias = "", chatItemTTL = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9880bb63b0..c1c87924a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1669,11 +1669,11 @@ object ChatController { return false } - suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): Boolean { + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): ChatInfo? { val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds)) - if (r.result is CR.CmdOk) return true + if (r is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}") - return false + return null } suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { @@ -1902,9 +1902,9 @@ object ChatController { } } - suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): GroupMember? { + suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): Pair? { val r = sendCmd(rh, CC.ApiAcceptMember(groupId, groupMemberId, memberRole)) - if (r is API.Result && r.res is CR.MemberAccepted) return r.res.member + if (r is API.Result && r.res is CR.MemberAccepted) return r.res.groupInfo to r.res.member if (!(networkErrorAlert(r))) { apiErrorAlert("apiAcceptMember", generalGetString(MR.strings.error_accepting_member), r) } @@ -2430,9 +2430,6 @@ object ChatController { if (cItem.isActiveReport) { chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } - if (cInfo.groupChatScope() != null && cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { - chatModel.chatsContext.increaseGroupSupportChatsUnreadCounter(rhId, cInfo.id) - } } withContext(Dispatchers.Main) { chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) @@ -2586,6 +2583,7 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } } is CR.DeletedMemberUser -> // TODO update user member @@ -5796,6 +5794,7 @@ sealed class CR { @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("itemsReadForChat") class ItemsReadForChat(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @@ -5976,6 +5975,7 @@ sealed class CR { is ContactAlreadyExists -> "contactAlreadyExists" is ContactDeleted -> "contactDeleted" is ContactDeletedByContact -> "contactDeletedByContact" + is ItemsReadForChat -> "itemsReadForChat" is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" @@ -6146,6 +6146,7 @@ sealed class CR { is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact)) is ContactDeletedByContact -> withUser(user, json.encodeToString(contact)) + is ItemsReadForChat -> withUser(user, json.encodeToString(chatInfo)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) 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 84d811d1a9..01a3622357 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 @@ -633,17 +633,17 @@ fun ChatView( withBGApi { withContext(Dispatchers.Main) { chatModel.chatsContext.markChatItemsRead(chatRh, chatInfo.id, itemsIds) - if (chatsCtx.secondaryContextFilter != null) { - chatModel.chatsContext.decreaseGroupSupportChatsUnreadCounter(chatRh, chatInfo.id) - } ntfManager.cancelNotificationsForChat(chatInfo.id) - chatModel.controller.apiChatItemsRead( + val updatedChatInfo = chatModel.controller.apiChatItemsRead( chatRh, chatInfo.chatType, chatInfo.apiId, chatInfo.groupChatScope(), itemsIds ) + if (updatedChatInfo != null) { + chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + } } withContext(Dispatchers.Main) { chatModel.secondaryChatsContext.value?.markChatItemsRead(chatRh, chatInfo.id, itemsIds) @@ -894,10 +894,10 @@ fun ChatLayout( } } val reportsCount = reportsCount(chatInfo?.id) - val supportChatsUnreadCount = supportChatsUnreadCount(chatInfo?.id) + val supportUnreadCount = supportUnreadCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0)) { - SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = true, showReports, showSupportChats) + if (chatInfo != null && chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0)) { + SupportChatsCountToolbar(chatInfo, reportsCount, supportUnreadCount, withStatusBar = true, showReports, showSupportChats) } else { StatusBarBackground() } @@ -953,8 +953,8 @@ fun ChatLayout( SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if ((reportsCount > 0 || supportChatsUnreadCount > 0) && (!oneHandUI.value || !chatBottomBar.value)) { - SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = false, showReports, showSupportChats) + if (chatInfo != null && (reportsCount > 0 || supportUnreadCount > 0) && (!oneHandUI.value || !chatBottomBar.value)) { + SupportChatsCountToolbar(chatInfo, reportsCount, supportUnreadCount, withStatusBar = false, showReports, showSupportChats) } } } @@ -1187,8 +1187,9 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo @Composable private fun SupportChatsCountToolbar( + chatInfo: ChatInfo, reportsCount: Int, - supportChatsUnreadCount: Int, + supportUnreadCount: Int, withStatusBar: Boolean, showReports: () -> Unit, showSupportChats: () -> Unit @@ -1225,7 +1226,7 @@ private fun SupportChatsCountToolbar( } } - if (supportChatsUnreadCount > 0) { + if (supportUnreadCount > 0) { Row( Modifier .fillMaxWidth() @@ -1240,10 +1241,13 @@ private fun SupportChatsCountToolbar( Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.primary) Spacer(Modifier.width(4.dp)) Text( - if (appPlatform.isAndroid) - stringResource(MR.strings.group_new_support_messages_short).format(supportChatsUnreadCount) + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) + if (appPlatform.isAndroid) + stringResource(MR.strings.group_new_support_chats_short).format(supportUnreadCount) + else + stringResource(MR.strings.group_new_support_chats).format(supportUnreadCount) else - stringResource(MR.strings.group_new_support_messages).format(supportChatsUnreadCount), + stringResource(MR.strings.group_new_support_messages).format(supportUnreadCount), style = MaterialTheme.typography.button ) } @@ -1329,10 +1333,10 @@ fun BoxScope.ChatItemsList( } val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } val reportsCount = reportsCount(chatInfo.id) - val supportChatsUnreadCount = supportChatsUnreadCount(chatInfo.id) + val supportUnreadCount = supportUnreadCount(chatInfo.id) val topPaddingToContent = topPaddingToContent( chatView = chatsCtx.secondaryContextFilter == null, - additionalTopBar = chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0) + additionalTopBar = chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0) ) val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) val numberOfBottomAppBars = numberOfBottomAppBars() @@ -1672,7 +1676,7 @@ fun BoxScope.ChatItemsList( ), reverseLayout = true, additionalBarOffset = composeViewHeight, - additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0)), + additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0)), chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -2358,11 +2362,11 @@ fun reportsCount(staleChatId: String?): Int { } @Composable -fun supportChatsUnreadCount(staleChatId: String?): Int { +fun supportUnreadCount(staleChatId: String?): Int { return if (staleChatId?.startsWith("#") != true) { 0 } else { - remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId }?.chatStats } }.value?.supportChatsUnreadCount ?: 0 + remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId } } }.value?.supportUnreadCount ?: 0 } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 8f1d40dd58..401509a171 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -102,13 +102,14 @@ fun acceptMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, role: GroupMemberRole, close: (() -> Unit)?) { withBGApi { - val acceptedMember = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) - if (acceptedMember != null) { + val r = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) + if (r != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, acceptedMember) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) } withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, acceptedMember) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, r.second) } } close?.invoke() 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 9a27dd150b..006456004b 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 @@ -741,10 +741,10 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { @Composable private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { SettingsActionItem( - painterResource(if (chat.chatStats.supportChatsUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), + painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.member_support), click = onClick, - iconColor = (if (chat.chatStats.supportChatsUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) + iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) ) } 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 584a358c5e..eb7eb74103 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 @@ -364,7 +364,7 @@ fun ChatPreviewView( progressView() } else if (chat.chatStats.reportsCount > 0) { GroupReportsIcon() - } else if (chat.chatStats.supportChatsUnreadCount > 0) { + } else if (chat.supportUnreadCount > 0) { GroupSupportUnreadIcon() } else { IncognitoIcon(chat.chatInfo.incognito) 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 4cc727ba4f..acd57f7ffc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -465,8 +465,9 @@ 1 report %d reports Member reports - %d new support messages - %d messages + %d messages + %d chats with members + %d chats Share message… diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c8151dbac5..7653322c8d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -676,6 +676,7 @@ data ChatResponse | CRSentConfirmation {user :: User, connection :: PendingContactConnection} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} + | CRItemsReadForChat {user :: User, chatInfo :: AChatInfo} | CRContactDeleted {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} | CRUserContactLinkCreated {user :: User, connLinkContact :: CreatedLinkContact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index e58ee17c7b..959fab7021 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1017,22 +1017,26 @@ processChatCommand' vr = \case CTContactConnection -> throwCmdError "not supported" APIChatItemsRead chatRef@(ChatRef cType chatId scope) itemIds -> withUser $ \_ -> case cType of CTDirect -> do - user <- withFastStore $ \db -> getUserByContactId db chatId + (user, ct) <- withFastStore $ \db -> do + user <- getUserByContactId db chatId + ct <- getContact db vr user chatId + pure (user, ct) timedItems <- withFastStore' $ \db -> do timedItems <- updateDirectChatItemsReadList db user chatId itemIds setDirectChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + pure $ CRItemsReadForChat user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do (user, gInfo) <- withFastStore $ \db -> do user <- getUserByGroupId db chatId gInfo <- getGroupInfo db vr user chatId pure (user, gInfo) - timedItems <- withFastStore' $ \db -> do - timedItems <- updateGroupChatItemsReadList db user gInfo scope itemIds - setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + (timedItems, gInfo') <- withFastStore $ \db -> do + (timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo scope itemIds + timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + pure (timedItems', gInfo') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + pure $ CRItemsReadForChat user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) CTLocal -> throwCmdError "not supported" CTContactRequest -> throwCmdError "not supported" CTContactConnection -> throwCmdError "not supported" @@ -2054,8 +2058,11 @@ processChatCommand' vr = \case void $ sendDirectMemberMessage mConn msg groupId introduceToRemaining vr user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m - m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m GSMemConnected role - pure $ CRMemberAccepted user gInfo m' + (m', gInfo') <- withFastStore' $ \db -> do + m' <- updateGroupMemberAccepted db user m GSMemConnected role + gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' + pure (m', gInfo') + pure $ CRMemberAccepted user gInfo' m' Nothing -> throwChatError CEGroupMemberNotActive GSMemPendingReview -> do let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) @@ -2063,8 +2070,11 @@ processChatCommand' vr = \case let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg - m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m newMemberStatus role - pure $ CRMemberAccepted user gInfo m' + (m', gInfo') <- withFastStore' $ \db -> do + m' <- updateGroupMemberAccepted db user m newMemberStatus role + gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' + pure (m', gInfo') + pure $ CRMemberAccepted user gInfo' m' where newMemberStatus = case memberConn m of Just c | connReady c -> GSMemConnected diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 3a26ece6f7..7809d8cc68 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2032,8 +2032,9 @@ saveSndChatItems :: CM [Either ChatError (ChatItem c 'MDSnd)] saveSndChatItems user cd itemsData itemTimed live = do createdAt <- liftIO getCurrentTime + vr <- chatVersionRange when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - withStore' (\db -> updateChatTsStats db user cd createdAt Nothing) + void $ withStore' (\db -> updateChatTsStats db vr 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)) @@ -2045,19 +2046,20 @@ saveSndChatItems user cd itemsData itemTimed live = do CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci -saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) +saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv) +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) ciContentNoParse content = (content, (ciContentToText content, Nothing)) -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv) +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime + vr <- chatVersionRange withStore' $ \db -> do (mentions' :: Map MemberName CIMention, userMention) <- case cd of CDGroupRcv g@GroupInfo {membership} _scope _m -> do @@ -2068,13 +2070,16 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' in pure (mentions', userMention') CDDirectRcv _ -> pure (M.empty, False) - when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTsStats db user cd createdAt (chatStatsCounts userMention) + cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) + then updateChatTsStats db vr user cd createdAt (chatStatsCounts userMention) + else pure $ toChatInfo cd (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt - case cd of + ci' <- case cd of CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci + pure (ci', cInfo') where chatStatsCounts :: Bool -> Maybe (Int, MemberAttention, Int) chatStatsCounts userMention = case cd of @@ -2297,12 +2302,13 @@ createInternalItemsForChats :: createInternalItemsForChats user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ - void . withStoreBatch' $ \db -> map (uncurry $ updateChat db createdAt) dirsCIContents + vr <- chatVersionRange' + void . withStoreBatch' $ \db -> map (uncurry $ updateChat db vr createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (uncurry $ createACIs db itemTs createdAt) dirsCIContents where - updateChat :: DB.Connection -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () - updateChat db createdAt cd contents - | any ciRequiresAttention contents || contactChatDeleted cd = updateChatTsStats db user cd createdAt chatStatsCounts + updateChat :: DB.Connection -> VersionRangeChat -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () + updateChat db vr createdAt cd contents + | any ciRequiresAttention contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt chatStatsCounts | otherwise = pure () where chatStatsCounts :: Maybe (Int, MemberAttention, Int) @@ -2331,7 +2337,8 @@ createLocalChatItems :: UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do - withStore' $ \db -> updateChatTsStats db user cd createdAt Nothing + vr <- chatVersionRange + void $ withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) unless (null errs) $ toView $ CEvtChatErrors errs pure items diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 6fb09d56e9..d978ae7bc1 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -784,29 +784,33 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = memberConnectedChatItem gInfo' scopeInfo m' unless (memberPending membership) $ maybeCreateGroupDescrLocal gInfo' m' GCInviteeMember -> do - mStatus <- + (gInfo', mStatus) <- if not (memberPending m) - then withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected - else pure $ memberStatus m - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - memberConnectedChatItem gInfo' scopeInfo m' + then do + mStatus <- withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected + pure (gInfo, mStatus) + else do + gInfo' <- withStore' $ \db -> increaseGroupMembersRequireAttention db user gInfo + pure (gInfo', memberStatus m) + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + memberConnectedChatItem gInfo'' scopeInfo m' case scopeInfo of Just (GCSIMemberSupport _) -> do - createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing + createInternalChatItem user (CDGroupRcv gInfo'' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing _ -> pure () - toView $ CEvtJoinedGroupMember user gInfo' m' {memberStatus = mStatus} + toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn - when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo' + when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' when (connChatVersion < batchSend2Version) sendGroupAutoReply case mStatus of GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators vr user gInfo' m' + GSMemPendingReview -> introduceToModerators vr user gInfo'' m' _ -> do - introduceToAll vr user gInfo' m' - when (groupFeatureAllowed SGFHistory gInfo') $ sendHistory user gInfo' m' + introduceToAll vr user gInfo'' m' + when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' where - sendXGrpLinkMem gInfo' = do - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo' + sendXGrpLinkMem gInfo'' = do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo'' profileToSend = profileToSendOnAccept user profileMode True void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do @@ -1466,9 +1470,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where brokerTs = metaBrokerTs msgMeta newChatItem content ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci {reactions}] autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> CM () autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do @@ -1534,11 +1538,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl ts = ciContentTexts content - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content True live Nothing Nothing - toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv cInfo ci') where brokerTs = metaBrokerTs msgMeta content = CIRcvMsgContent mc @@ -1658,27 +1662,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | groupFeatureAllowed SGFFullDelete gInfo = do (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ -- ignores member role when blocked by admin - ci <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty + (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo' ci brokerTs - groupMsgToView gInfo' scopeInfo ci' + groupMsgToView cInfo ci' | otherwise = do file_ <- processFileInv - ci <- createNonLive file_ + (ci, cInfo) <- createNonLive file_ ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci - (gInfo', _m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - groupMsgToView gInfo' scopeInfo ci' + groupMsgToView cInfo ci' applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - ci <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty + (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo' ci moderator moderatedAt - groupMsgToView gInfo' scopeInfo ci' + groupMsgToView cInfo ci' | otherwise = do (gInfo', _m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ file_ <- processFileInv - ci <- createNonLive file_ + (ci, _cInfo) <- createNonLive file_ deletions <- markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt toView $ CEvtChatItemsDeleted user deletions False False createNonLive file_ = do @@ -1693,10 +1696,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newChatItem ciContent ciFile_ timed_ live = do let mentions' = if showMessages (memberSettings m) then mentions else [] (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - ci <- saveRcvCI gInfo' scopeInfo m' ciContent ciFile_ timed_ live mentions' + (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' ciContent ciFile_ timed_ live mentions' ci' <- blockedMember m' ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo' ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo' scopeInfo ci' {reactions} + groupMsgToView cInfo ci' {reactions} groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ @@ -1710,12 +1713,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let timed_ = rcvGroupCITimed gInfo ttl_ mentions' = if showMessages (memberSettings m) then mentions else [] (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' + (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc ci' <- updateGroupChatItem db user groupId ci content True live Nothing blockedMember m' ci' $ markGroupChatItemBlocked db user gInfo' ci' - toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo' scopeInfo) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc @@ -1802,8 +1805,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] where brokerTs = metaBrokerTs msgMeta @@ -1816,9 +1819,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" - ci <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - groupMsgToView gInfo Nothing ci' + groupMsgToView cInfo ci' blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) blockedMember m ci blockedCI @@ -1969,9 +1972,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" - groupMsgToView :: forall d. MsgDirectionI d => GroupInfo -> Maybe GroupChatScopeInfo -> ChatItem 'CTGroup d -> CM () - groupMsgToView gInfo chatScopeInfo ci = do - toView $ CEvtNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo chatScopeInfo) ci] + groupMsgToView :: forall d. MsgDirectionI d => ChatInfo 'CTGroup -> ChatItem 'CTGroup d -> CM () + groupMsgToView cInfo ci = do + toView $ CEvtNewChatItems user [AChatItem SCTGroup (msgDirection @d) cInfo ci] processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> CM () processGroupInvitation ct inv msg msgMeta = do @@ -1996,9 +1999,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where brokerTs = metaBrokerTs msgMeta @@ -2024,8 +2027,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} let ct'' = ct' {activeConn = activeConn'} :: Contact - ci <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci] + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] toView $ CEvtContactDeletedByContact user ct'' else do contactConns <- withStore' $ \db -> getContactConnections db vr userId c @@ -2096,13 +2099,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" Right referencedMember -> do - referencedMember' <- withFastStore' $ \db -> updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role + (referencedMember', gInfo') <- withFastStore' $ \db -> do + referencedMember' <- updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role + gInfo' <- updateGroupMembersRequireAttention db user gInfo referencedMember referencedMember' + pure (referencedMember', gInfo') when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Just referencedMember'} gEvent = RGEMemberAccepted (groupMemberId' referencedMember') (fromLocalProfile $ memberProfile referencedMember') - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo scopeInfo ci - toView $ CEvtMemberAcceptedByOther user gInfo m referencedMember' + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci + toView $ CEvtMemberAcceptedByOther user gInfo' m referencedMember' where newMemberStatus refMem = case memberConn refMem of Just c | connReady c -> GSMemConnected @@ -2112,8 +2118,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = GAAccepted -> do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) - groupMsgToView gInfo scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) + groupMsgToView cInfo ci toView $ CEvtUserJoinedGroup user gInfo {membership = membership'} m let cd = CDGroupRcv gInfo Nothing m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing @@ -2265,7 +2271,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do g <- asks random dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing - ci <- saveCallItem CISCallPending + (ci, cInfo) <- saveCallItem CISCallPending callUUID <- UUID.toText <$> liftIO V4.nextRandom let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} @@ -2278,15 +2284,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CEvtCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci} - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] else featureRejected CFCalls where brokerTs = metaBrokerTs msgMeta saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do let content = ciContentNoParse $ CIRcvChatFeatureRejected f - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] -- to party initiating call xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> CM () @@ -2457,27 +2463,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (sameMemberId memId $ membership gInfo) $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus - toView $ CEvtUnknownMemberAnnounced user gInfo m unknownMember updatedMember - memberAnnouncedToView updatedMember + (updatedMember, gInfo') <- withStore $ \db -> do + updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus + gInfo' <- if memberPending updatedMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (updatedMember, gInfo') + toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo' Right _ -> messageError "x.grp.mem.new error: member already exists" Left _ -> do - newMember <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus - memberAnnouncedToView newMember + (newMember, gInfo') <- withStore $ \db -> do + newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus + gInfo' <- if memberPending newMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (newMember, gInfo') + memberAnnouncedToView newMember gInfo' where initialStatus = case msgScope_ of Just (MSMember _) -> GSMemPendingReview _ -> GSMemAnnounced - memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do + memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} gInfo' = do (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent event) - groupMsgToView gInfo scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent event) + groupMsgToView cInfo ci case scopeInfo of Just (GCSIMemberSupport _) -> do - createInternalChatItem user (CDGroupRcv gInfo scopeInfo m) (CIRcvGroupEvent RGENewMemberPendingReview) (Just brokerTs) + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGENewMemberPendingReview) (Just brokerTs) _ -> pure () - toView $ CEvtJoinedGroupMemberConnecting user gInfo m announcedMember' + toView $ CEvtJoinedGroupMemberConnecting user gInfo' m announcedMember' getMemNewChatScope announcedMember = case msgScope_ of Nothing -> pure (announcedMember, Nothing) Just (MSMember _) -> do @@ -2571,8 +2587,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo'' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> CM () @@ -2600,8 +2616,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toggleNtf bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent - groupMsgToView gInfo' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent + groupMsgToView cInfo ci toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId @@ -2685,8 +2701,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = a deleteMemberItem gEvent = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci deleteMessages :: MsgDirectionI d => GroupMember -> SMsgDirection d -> CM () deleteMessages delMem msgDir | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupMemberCIs user gInfo delMem m msgDir @@ -2698,8 +2714,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) - groupMsgToView gInfo' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + groupMsgToView cInfo ci toView $ CEvtLeftMember user gInfo' m' {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () @@ -2712,8 +2728,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- member records are not deleted to keep history deleteMembersConnections user ms (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) - groupMsgToView gInfo'' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + groupMsgToView cInfo ci toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM () @@ -2726,8 +2742,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtGroupUpdated user g g'' (Just m') let cd = CDGroupRcv g'' scopeInfo m' unless (sameGroupProfileInfo p p') $ do - ci <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') - groupMsgToView g'' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') + groupMsgToView cInfo ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' @@ -2787,8 +2803,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CEvtNewMemberContactReceivedInv user mCt' g' m'' forM_ mContent_ $ \mc -> do - ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci] + (ci, cInfo) <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] securityCodeChanged :: Contact -> CM () securityCodeChanged ct = do diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 8aa595bed8..00dd999139 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -350,14 +350,13 @@ data ChatStats = ChatStats { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API unreadMentions :: Int, -- returned both in /_get chat initial API and in /_get chats API reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API - supportChatsUnreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API minUnreadItemId :: ChatItemId, unreadChat :: Bool } deriving (Show) emptyChatStats :: ChatStats -emptyChatStats = ChatStats 0 0 0 0 0 False +emptyChatStats = ChatStats 0 0 0 0 False data NavigationInfo = NavigationInfo { afterUnread :: Int, diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 8407cb11a7..121796f3fd 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -137,7 +137,8 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 24f598a7e6..710ee50e09 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -80,6 +80,8 @@ module Simplex.Chat.Store.Groups updateGroupMemberStatus, updateGroupMemberStatusById, updateGroupMemberAccepted, + updateGroupMembersRequireAttention, + increaseGroupMembersRequireAttention, createNewGroupMember, checkGroupMemberHasItems, deleteGroupMember, @@ -279,7 +281,8 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -362,7 +365,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + membersRequireAttention = 0 } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -432,7 +436,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + membersRequireAttention = 0 }, groupMemberId ) @@ -768,7 +773,8 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -1224,6 +1230,37 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status (status, role, currentTs, userId, groupMemberId) pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} +updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo +updateGroupMembersRequireAttention db user@User {userId} g@GroupInfo {groupId, membersRequireAttention} member member' + | nowRequires && not didRequire = + increaseGroupMembersRequireAttention db user g + | not nowRequires && didRequire = do + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention - 1} + | otherwise = pure g + where + didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + +increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo +increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + 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 @@ -1561,7 +1598,8 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 39ccb698fa..83d34e5d24 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -369,56 +369,103 @@ type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bo data MemberAttention = MAInc Int | MAReset deriving (Show) -updateChatTsStats :: DB.Connection -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO () -updateChatTsStats db User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of - DirectChat Contact {contactId} -> +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 + DirectChat ct@Contact {contactId} -> do DB.execute db "UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ?" (chatTs, userId, contactId) - GroupChat GroupInfo {groupId, membership} scopeInfo -> do + pure $ DirectChat ct {chatTs = Just chatTs} + GroupChat g@GroupInfo {groupId} Nothing -> do DB.execute db "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" (chatTs, userId, groupId) - forM_ scopeInfo $ \GCSIMemberSupport {groupMember_} -> do - let gmId = groupMemberId' $ fromMaybe membership groupMember_ - case chatStats_ of - Nothing -> - DB.execute - db - "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" - (chatTs, gmId) - Just (unread, MAInc unanswered, mentions) -> - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = support_chat_items_member_attention + ?, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - |] - (chatTs, unread, unanswered, mentions, gmId) - Just (unread, MAReset, mentions) -> - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = 0, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - |] - (chatTs, unread, mentions, gmId) - LocalChat NoteFolder {noteFolderId} -> + pure $ GroupChat g {chatTs = Just chatTs} Nothing + GroupChat g@GroupInfo {groupId, membership, membersRequireAttention} (Just GCSIMemberSupport {groupMember_}) -> + case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + DB.execute + db + "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" + (chatTs, userId, groupId) + pure $ GroupChat g {membership = membership', chatTs = Just chatTs} (Just $ GCSIMemberSupport Nothing) + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if + | nowRequires && not didRequire -> do + DB.execute + db + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + |] + (chatTs, userId, groupId) + pure $ GroupChat g {membersRequireAttention = membersRequireAttention + 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + | not nowRequires && didRequire -> do + DB.execute + db + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (chatTs, userId, groupId) + pure $ GroupChat g {membersRequireAttention = membersRequireAttention - 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + | otherwise -> do + DB.execute + db + "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" + (chatTs, userId, groupId) + pure $ GroupChat g {chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + where + updateGMStats m@GroupMember {groupMemberId} = do + case chatStats_ of + Nothing -> + DB.execute + db + "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" + (chatTs, groupMemberId) + Just (unread, MAInc unanswered, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, unanswered, mentions, groupMemberId) + Just (unread, MAReset, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, mentions, groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + LocalChat nf@NoteFolder {noteFolderId} -> do DB.execute db "UPDATE note_folders SET chat_ts = ? WHERE user_id = ? AND note_folder_id = ?" (chatTs, userId, noteFolderId) - _ -> pure () + pure $ LocalChat nf {chatTs = chatTs} + cInfo -> pure cInfo setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () setSupportChatTs db groupMemberId chatTs = @@ -636,13 +683,13 @@ type ChatStatsRow = (Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId, BI unreadChat) = - ChatStats {unreadCount, unreadMentions = 0, reportsCount = 0, supportChatsUnreadCount = 0, minUnreadItemId, unreadChat} + ChatStats {unreadCount, unreadMentions = 0, reportsCount = 0, minUnreadItemId, unreadChat} -type GroupStatsRow = (Int, Int, Int, Int, ChatItemId, BoolInt) +type GroupStatsRow = (Int, Int, Int, ChatItemId, BoolInt) toGroupStats :: GroupStatsRow -> ChatStats -toGroupStats (unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, BI unreadChat) = - ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat} +toGroupStats (unreadCount, unreadMentions, reportsCount, minUnreadItemId, BI unreadChat) = + ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -763,7 +810,6 @@ findGroupChatPreviews_ db User {userId} pagination clq = COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -780,16 +826,8 @@ findGroupChatPreviews_ db User {userId} pagination clq = AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id |] - baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False, userId, CISRcvNew) + baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False) getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> do let q = baseQuery <> " WHERE g.user_id = ?" @@ -1494,16 +1532,14 @@ getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do where getStats minUnreadItemId (unreadCount, unreadMentions) = do reportsCount <- getGroupReportsCount_ db user g False - supportChatsUnreadCount <- getGroupSupportChatsUnreadCount_ db user g - pure ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} getGroupStats_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> ExceptT StoreError IO ChatStats getGroupStats_ db user g scopeInfo_ = do minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g scopeInfo_ Nothing (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g scopeInfo_ Nothing reportsCount <- liftIO $ getGroupReportsCount_ db user g False - supportChatsUnreadCount <- liftIO $ getGroupSupportChatsUnreadCount_ db user g - pure ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) getGroupMinUnreadId_ db user g scopeInfo_ contentFilter = @@ -1527,14 +1563,6 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" (userId, groupId, MCReport_, BI archived) -getGroupSupportChatsUnreadCount_ :: DB.Connection -> User -> GroupInfo -> IO Int -getGroupSupportChatsUnreadCount_ db User {userId} GroupInfo {groupId} = - fromOnly . head - <$> DB.query - db - "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NOT NULL AND item_status = ?" - (userId, groupId, CISRcvNew) - queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> ExceptT StoreError IO [r] queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter baseQuery orderLimit = case (scopeInfo_, contentFilter) of @@ -1954,14 +1982,14 @@ getGroupUnreadTimedItems db User {userId} groupId = |] (userId, groupId, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] -updateGroupChatItemsReadList db User {userId} GroupInfo {groupId, membership} scope itemIds = do - currentTs <- getCurrentTime +updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) +updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId, membership, membersRequireAttention} scope 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. - readItemsData <- catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds - updateChatStats readItemsData - pure $ timedItems readItemsData + readItemsData <- liftIO $ catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds + g' <- updateChatStats readItemsData + pure (timedItems readItemsData, g') where getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) getUpdateGroupItem currentTs itemId = @@ -1974,36 +2002,61 @@ updateGroupChatItemsReadList db User {userId} GroupInfo {groupId, membership} sc RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) - updateChatStats :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> IO () + updateChatStats :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> ExceptT StoreError IO GroupInfo updateChatStats readItemsData = case scope of - Nothing -> pure () - Just GCSMemberSupport {groupMemberId_} -> do - let unread = length readItemsData - (unanswered, mentions) = decStats - gmId = fromMaybe (groupMemberId' membership) groupMemberId_ - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ? - WHERE group_member_id = ? - |] - (unread, unanswered, mentions, gmId) + Nothing -> pure g + Just GCSMemberSupport {groupMemberId_} -> case groupMemberId_ of + Nothing -> do + membership' <- updateGMStats membership + pure g {membership = membership'} + Just groupMemberId -> do + member <- getGroupMemberById db vr user groupMemberId + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if (not nowRequires && didRequire) + then do + liftIO $ + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention - 1} + else + pure g where - decStats :: (Int, Int) - decStats = foldl' countItem (0, 0) readItemsData + updateGMStats GroupMember {groupMemberId} = do + let unread = length readItemsData + (unanswered, mentions) = decStats + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ? + WHERE group_member_id = ? + |] + (unread, unanswered, mentions, groupMemberId) + getGroupMemberById db vr user groupMemberId where - countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) - countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = - let unanswered' = case (groupMemberId_, itemGMId_) of - (Just scopeGMId, Just itemGMId) | itemGMId == scopeGMId -> unanswered + 1 - _ -> unanswered - mentions' = case userMention_ of - Just (BI True) -> mentions + 1 - _ -> mentions - in (unanswered', mentions') + decStats :: (Int, Int) + decStats = foldl' countItem (0, 0) readItemsData + where + countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) + countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = + let unanswered' = case (groupMemberId_, itemGMId_) of + (Just scopeGMId, Just itemGMId) | itemGMId == scopeGMId -> unanswered + 1 + _ -> unanswered + mentions' = case userMention_ of + Just (BI True) -> mentions + 1 + _ -> mentions + in (unanswered', mentions') timedItems :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> [(ChatItemId, Int)] timedItems = foldl' addTimedItem [] where diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs index 0dcd47527e..4c5c20c99f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs @@ -17,6 +17,8 @@ ALTER TABLE group_members ADD COLUMN support_chat_items_unread BIGINT NOT NULL D ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention BIGINT NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_mentions BIGINT NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN members_require_attention BIGINT NOT NULL DEFAULT 0; + ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE; @@ -53,6 +55,8 @@ DROP INDEX idx_chat_items_group_scope_group_member_id; ALTER TABLE chat_items DROP COLUMN group_scope_tag; ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; +ALTER TABLE groups DROP COLUMN members_require_attention; + ALTER TABLE group_members DROP COLUMN support_chat_ts; ALTER TABLE group_members DROP COLUMN support_chat_items_unread; ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs index d17e969866..883464fa83 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs @@ -15,6 +15,8 @@ ALTER TABLE group_members ADD COLUMN support_chat_items_unread INTEGER NOT NULL ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_mentions INTEGER NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN members_require_attention INTEGER NOT NULL DEFAULT 0; + ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; @@ -50,6 +52,8 @@ DROP INDEX idx_chat_items_group_scope_group_member_id; ALTER TABLE chat_items DROP COLUMN group_scope_tag; ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; +ALTER TABLE groups DROP COLUMN members_require_attention; + ALTER TABLE group_members DROP COLUMN support_chat_ts; ALTER TABLE group_members DROP COLUMN support_chat_items_unread; ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; 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 f4ba1c1e4e..4fbd9858e6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1,3 +1,39 @@ +Query: + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: INSERT INTO group_members ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, @@ -36,7 +72,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -81,6 +118,28 @@ Query: Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: INSERT INTO contact_requests (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, @@ -621,38 +680,6 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = 0, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = support_chat_items_member_attention + ?, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? @@ -840,7 +867,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -887,7 +915,8 @@ Query: SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -1270,16 +1299,6 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET support_chat_items_unread = 0, - support_chat_items_member_attention = 0, - support_chat_items_mentions = 0 - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_profiles SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? @@ -1294,6 +1313,14 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE user_contact_links SET auto_accept = ?, business_address = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? @@ -1948,7 +1975,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -1965,14 +1991,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE g.user_id = ? @@ -1988,13 +2006,10 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2012,7 +2027,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2029,14 +2043,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.favorite = 1 @@ -2047,12 +2053,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2070,7 +2073,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2087,14 +2089,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) @@ -2104,12 +2098,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts 0) @@ -2161,12 +2143,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2184,7 +2163,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2201,14 +2179,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) @@ -2218,12 +2188,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2241,7 +2208,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2258,14 +2224,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.favorite = 1 @@ -2275,12 +2233,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2355,7 +2298,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2372,14 +2314,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.favorite = 1 @@ -2389,12 +2323,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2412,7 +2343,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2429,26 +2359,15 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2520,7 +2427,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2537,26 +2443,15 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -4526,6 +4421,14 @@ Query: Plan: SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) +Query: + UPDATE groups + SET members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) @@ -4625,7 +4528,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -4648,7 +4552,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -5638,10 +5543,6 @@ Query: SELECT COUNT(1) FROM chat_item_versions WHERE chat_item_id = ? Plan: SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) -Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NOT NULL AND item_status = ? -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag>?) - Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index e1cefaa1d1..552d753593 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -134,7 +134,8 @@ CREATE TABLE groups( business_xcontact_id BLOB NULL, customer_member_id BLOB NULL, chat_item_ttl INTEGER, - local_alias TEXT DEFAULT '', -- received + local_alias TEXT DEFAULT '', + members_require_attention INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index d876c9bd92..f0c0c86716 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -579,18 +579,18 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64, Int) :. GroupMemberRow type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions)) = @@ -622,7 +622,8 @@ groupInfoQuery = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, 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.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index da999b96bf..1cd0486aad 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -421,7 +421,8 @@ data GroupInfo = GroupInfo chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, - customData :: Maybe CustomData + customData :: Maybe CustomData, + membersRequireAttention :: Int } deriving (Eq, Show) @@ -854,6 +855,13 @@ data GroupSupportChat = GroupSupportChat } deriving (Eq, Show) +gmRequiresAttention :: GroupMember -> Bool +gmRequiresAttention m@GroupMember {supportChat} = + memberPending m || maybe False supportChatAttention supportChat + where + supportChatAttention GroupSupportChat {memberAttention, mentions} = + memberAttention > 0 || mentions > 0 + data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e6e91c154e..2e08afeaef 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -192,6 +192,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView + CRItemsReadForChat u chatId -> ttyUser u ["items read for chat"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 817e16113f..21ee1e9218 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -372,7 +372,7 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do bob ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine bob let itemIds = intercalate "," $ map show [i - 3 .. i] - bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") + bob #$> ("/_read chat items @2 " <> itemIds, id, "items read for chat") testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do @@ -391,7 +391,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map itemId [1 .. 2] - bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") + bob #$> ("/_read chat items @2 " <> itemIds, id, "items read for chat") bob #$> ("/_get chat @2 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) -- Read all items diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 49acf71ecb..687a7fde81 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -371,7 +371,7 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do bob ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine bob let itemIds = intercalate "," $ map show [i - 3 .. i] - bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") + bob #$> ("/_read chat items #1 " <> itemIds, id, "items read for chat") testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do @@ -392,7 +392,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map groupItemId [1 .. 2] - bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") + bob #$> ("/_read chat items #1 " <> itemIds, id, "items read for chat") bob #$> ("/_get chat #1 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) -- Read all items @@ -7072,12 +7072,12 @@ testScopedSupportUnreadStats = bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" - alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "ok") + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "items read for chat") alice ##> "/member support chats #team" alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1" - alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "ok") + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "items read for chat") alice ##> "/member support chats #team" alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" From 9190c2a772da911b65d345fe3b9e4bc57409c213 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 12 May 2025 12:19:20 +0000 Subject: [PATCH 28/72] core: reset unanswered counter for support chats with members only if moderator message is newer (#5889) --- src/Simplex/Chat/Library/Internal.hs | 37 ++++++------ src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 18 +++--- src/Simplex/Chat/Store/Messages.hs | 34 +++++++++-- .../Migrations/M20250403_group_scope.hs | 2 + .../Migrations/M20250403_group_scope.hs | 2 + .../SQLite/Migrations/chat_query_plans.txt | 56 +++++++++++++------ .../Store/SQLite/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Store/Shared.hs | 9 +-- src/Simplex/Chat/Types.hs | 3 +- tests/ChatTests/Groups.hs | 4 ++ 11 files changed, 115 insertions(+), 55 deletions(-) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 7809d8cc68..60be94ccc9 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1387,7 +1387,7 @@ mkGroupSupportChatInfo gInfo@GroupInfo {membership} = Nothing -> do chatTs <- liftIO getCurrentTime withStore' $ \db -> setSupportChatTs db (groupMemberId' membership) chatTs - let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 0 0 0}} + let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 0 0 0 Nothing}} scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} pure (gInfo', scopeInfo) Just _supportChat -> @@ -1400,7 +1400,7 @@ mkMemberSupportChatInfo m@GroupMember {groupMemberId, supportChat} = Nothing -> do chatTs <- liftIO getCurrentTime withStore' $ \db -> setSupportChatTs db groupMemberId chatTs - let m' = m {supportChat = Just $ GroupSupportChat chatTs 0 0 0} + let m' = m {supportChat = Just $ GroupSupportChat chatTs 0 0 0 Nothing} scopeInfo = GCSIMemberSupport {groupMember_ = Just m'} pure (m', scopeInfo) Just _supportChat -> @@ -2071,7 +2071,7 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared in pure (mentions', userMention') CDDirectRcv _ -> pure (M.empty, False) cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) - then updateChatTsStats db vr user cd createdAt (chatStatsCounts userMention) + then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) else pure $ toChatInfo cd (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt @@ -2081,11 +2081,11 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared _ -> pure ci pure (ci', cInfo') where - chatStatsCounts :: Bool -> Maybe (Int, MemberAttention, Int) - chatStatsCounts userMention = case cd of + memberChatStats :: Bool -> Maybe (Int, MemberAttention, Int) + memberChatStats userMention = case cd of CDGroupRcv _g (Just scope) m -> do let unread = fromEnum $ ciCreateStatus content == CISRcvNew - in Just (unread, memberAttentionChange unread m scope, fromEnum userMention) + in Just (unread, memberAttentionChange unread (Just brokerTs) m scope, fromEnum userMention) _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing @@ -2308,14 +2308,14 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do where updateChat :: DB.Connection -> VersionRangeChat -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () updateChat db vr createdAt cd contents - | any ciRequiresAttention contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt chatStatsCounts + | any ciRequiresAttention contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt memberChatStats | otherwise = pure () where - chatStatsCounts :: Maybe (Int, MemberAttention, Int) - chatStatsCounts = case cd of + memberChatStats :: Maybe (Int, MemberAttention, Int) + memberChatStats = case cd of CDGroupRcv _g (Just scope) m -> do let unread = length $ filter ciRequiresAttention contents - in Just (unread, memberAttentionChange unread m scope, 0) + in Just (unread, memberAttentionChange unread itemTs_ m scope, 0) _ -> Nothing createACIs :: DB.Connection -> UTCTime -> UTCTime -> ChatDirection c d -> [CIContent d] -> [IO AChatItem] createACIs db itemTs createdAt cd = map $ \content -> do @@ -2323,12 +2323,17 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci -memberAttentionChange :: Int -> GroupMember -> GroupChatScopeInfo -> MemberAttention -memberAttentionChange unread m = \case - GCSIMemberSupport (Just m') - | groupMemberId' m' == groupMemberId' m -> MAInc unread - | otherwise -> MAReset - GCSIMemberSupport Nothing -> MAInc 0 +memberAttentionChange :: Int -> (Maybe UTCTime) -> GroupMember -> GroupChatScopeInfo -> MemberAttention +memberAttentionChange unread brokerTs_ rcvMem = \case + GCSIMemberSupport (Just suppMem) + | groupMemberId' suppMem == groupMemberId' rcvMem -> MAInc unread brokerTs_ + | msgIsNewerThanLastUnanswered -> MAReset + | otherwise -> MAInc 0 Nothing + where + msgIsNewerThanLastUnanswered = case (supportChat suppMem >>= lastMsgFromMemberTs, brokerTs_) of + (Just lastMsgTs, Just brokerTs) -> lastMsgTs < brokerTs + _ -> False + GCSIMemberSupport Nothing -> MAInc 0 Nothing createLocalChatItems :: User -> diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 121796f3fd..b69f6f646c 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -145,12 +145,12 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, 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_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, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 710ee50e09..35e7fe96a9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -179,11 +179,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, 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, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, 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, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -289,12 +289,12 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, 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_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, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -778,7 +778,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, 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_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 FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -843,7 +843,7 @@ groupMemberQuery = m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -1606,12 +1606,12 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, 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_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, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 83d34e5d24..6331a3daf6 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -366,7 +366,16 @@ deleteOldMessages db createdAtCutoff = do type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId) -data MemberAttention = MAInc Int | MAReset +-- For support chats with members we track unanswered count - number of messages from the member +-- that weren't followed up by a message from any of moderators. +data MemberAttention + -- Message was received from member, increase unanswered counter and set support_chat_last_msg_from_member_ts. + -- `MAInc 0 Nothing` is used in two cases: + -- - when message from moderator is older than the last message from member (support_chat_last_msg_from_member_ts); + -- - for user's chat with moderators, where unanswered count is not tracked. + = MAInc Int (Maybe UTCTime) + -- Message was received from moderator, reset unanswered counter. + | MAReset deriving (Show) updateChatTsStats :: DB.Connection -> VersionRangeChat -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO (ChatInfo c) @@ -433,7 +442,7 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) - Just (unread, MAInc unanswered, mentions) -> + Just (unread, MAInc unanswered Nothing, mentions) -> DB.execute db [sql| @@ -445,6 +454,19 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas WHERE group_member_id = ? |] (chatTs, unread, unanswered, mentions, groupMemberId) + Just (unread, MAInc unanswered (Just lastMsgFromMemberTs), mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ?, + support_chat_last_msg_from_member_ts = ? + WHERE group_member_id = ? + |] + (chatTs, unread, unanswered, mentions, lastMsgFromMemberTs, groupMemberId) Just (unread, MAReset, mentions) -> DB.execute db @@ -625,7 +647,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -2857,7 +2879,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember @@ -2865,13 +2887,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, 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_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, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, 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_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 FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs index 4c5c20c99f..17d8764631 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs @@ -16,6 +16,7 @@ ALTER TABLE group_members ADD COLUMN support_chat_ts TIMESTAMPTZ; ALTER TABLE group_members ADD COLUMN support_chat_items_unread BIGINT NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention BIGINT NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_mentions BIGINT NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_last_msg_from_member_ts TIMESTAMPTZ; ALTER TABLE groups ADD COLUMN members_require_attention BIGINT NOT NULL DEFAULT 0; @@ -61,6 +62,7 @@ ALTER TABLE group_members DROP COLUMN support_chat_ts; ALTER TABLE group_members DROP COLUMN support_chat_items_unread; ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; ALTER TABLE group_members DROP COLUMN support_chat_items_mentions; +ALTER TABLE group_members DROP COLUMN support_chat_last_msg_from_member_ts; ALTER TABLE group_profiles DROP COLUMN member_admission; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs index 883464fa83..fe52add77b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs @@ -14,6 +14,7 @@ ALTER TABLE group_members ADD COLUMN support_chat_ts TEXT; ALTER TABLE group_members ADD COLUMN support_chat_items_unread INTEGER NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_mentions INTEGER NOT NULL DEFAULT 0; +ALTER TABLE group_members ADD COLUMN support_chat_last_msg_from_member_ts TEXT; ALTER TABLE groups ADD COLUMN members_require_attention INTEGER NOT NULL DEFAULT 0; @@ -58,6 +59,7 @@ ALTER TABLE group_members DROP COLUMN support_chat_ts; ALTER TABLE group_members DROP COLUMN support_chat_items_unread; ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; ALTER TABLE group_members DROP COLUMN support_chat_items_mentions; +ALTER TABLE group_members DROP COLUMN support_chat_last_msg_from_member_ts; ALTER TABLE group_profiles DROP COLUMN member_admission; |] 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 4fbd9858e6..76068e61e5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -80,12 +80,12 @@ Query: -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, 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_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, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -140,6 +140,18 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ?, + support_chat_last_msg_from_member_ts = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: INSERT INTO contact_requests (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, @@ -621,7 +633,7 @@ Query: m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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 FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -779,7 +791,7 @@ Query: m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember @@ -787,13 +799,13 @@ Query: rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, 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_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, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, 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_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 FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id @@ -875,12 +887,12 @@ Query: -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, 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_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, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -920,7 +932,7 @@ Query: mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, 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_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 FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -1299,6 +1311,16 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0 + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_profiles SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? @@ -4535,7 +4557,7 @@ 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, pu.display_name, pu.full_name, pu.image, pu.contact_link, 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_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 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 @@ -4559,7 +4581,7 @@ 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, pu.display_name, pu.full_name, pu.image, pu.contact_link, 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_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 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 @@ -4576,7 +4598,7 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4609,7 +4631,7 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4634,7 +4656,7 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4659,7 +4681,7 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4684,7 +4706,7 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4709,7 +4731,7 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, 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_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, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 552d753593..91670ffc7b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -172,6 +172,7 @@ CREATE TABLE group_members( support_chat_items_unread INTEGER NOT NULL DEFAULT 0, support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0, support_chat_items_mentions INTEGER NOT NULL DEFAULT 0, + support_chat_last_msg_from_member_ts TEXT, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index f0c0c86716..8c9c50e9de 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -581,7 +581,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64, Int) :. GroupMemberRow -type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64) +type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention) :. userMemberRow) = @@ -593,7 +593,7 @@ toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -606,7 +606,8 @@ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, chatTs, unread = supportChatUnread, memberAttention = supportChatMemberAttention, - mentions = supportChatMentions + mentions = supportChatMentions, + lastMsgFromMemberTs = supportChatLastMsgFromMemberTs } _ -> Nothing in GroupMember {..} @@ -629,7 +630,7 @@ groupInfoQuery = mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, 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_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 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 diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 1cd0486aad..567baf0cdf 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -851,7 +851,8 @@ data GroupSupportChat = GroupSupportChat { chatTs :: UTCTime, unread :: Int64, memberAttention :: Int64, - mentions :: Int64 + mentions :: Int64, + lastMsgFromMemberTs :: Maybe UTCTime } deriving (Eq, Show) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 687a7fde81..9b55b7cb37 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -7015,6 +7015,8 @@ testScopedSupportUnreadStats = bob ##> "/member support chats #team" bob <## "support: unread: 1, require attention: 0, mentions: 0" + threadDelay 1000000 + dan ##> "/_send #1(_support:3) text 5" dan <# "#team (support: bob) 5" alice <# "#team (support: bob) dan> 5" @@ -7082,6 +7084,8 @@ testScopedSupportUnreadStats = alice ##> "/member support chats #team" alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" + threadDelay 1000000 + dan ##> "/_send #1(_support:3) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@bob 9\"}, \"mentions\": {\"bob\": 3}}]" dan <# "#team (support: bob) @bob 9" alice <# "#team (support: bob) dan> @bob 9" From 671dc2537da5fd985adbff263e0858e6efa1dc44 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 12 May 2025 16:50:30 +0000 Subject: [PATCH 29/72] core: accepted message to older clients (#5897) --- src/Simplex/Chat/Library/Commands.hs | 4 ++++ src/Simplex/Chat/Messages/CIContent.hs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 959fab7021..c5006bf530 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2070,6 +2070,10 @@ processChatCommand' vr = \case let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg + when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ + forM_ (memberConn m) $ \mConn -> do + let msg = XMsgNew $ MCSimple $ extMsgContent (MCText acceptedToGroupMessage) Nothing + void $ sendDirectMemberMessage mConn msg groupId (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m newMemberStatus role gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 9fb3c2e131..0548af9d73 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -350,6 +350,11 @@ pendingReviewMessage :: Text pendingReviewMessage = "Please wait for group moderators to review your request to join the group." +-- used to send to members with old version +acceptedToGroupMessage :: Text +acceptedToGroupMessage = + "You are accepted to the group." + rcvConnEventToText :: RcvConnEvent -> Text rcvConnEventToText = \case RCESwitchQueue phase -> case phase of From db1ffb2cf26a001d1608c82ecc68eb8bd45845e0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 13 May 2025 12:35:54 +0000 Subject: [PATCH 30/72] core: create "accepted by you" item for moderator (#5901) --- apps/ios/SimpleXChat/ChatTypes.swift | 3 +++ .../kotlin/chat/simplex/common/model/ChatModel.kt | 4 +++- .../src/commonMain/resources/MR/base/strings.xml | 1 + src/Simplex/Chat/Library/Commands.hs | 10 ++++++++++ src/Simplex/Chat/Library/Subscriber.hs | 2 ++ src/Simplex/Chat/Messages/CIContent.hs | 1 + src/Simplex/Chat/Messages/CIContent/Events.hs | 1 + 7 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 2708c5aece..81fc500072 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2732,6 +2732,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { switch event { case .userRole: nil case .userLeft: nil + case .memberAccepted: nil case .userPendingReview: nil default: .sndGroupEvent } @@ -4626,6 +4627,7 @@ public enum SndGroupEvent: Decodable, Hashable { case memberDeleted(groupMemberId: Int64, profile: Profile) case userLeft case groupUpdated(groupProfile: GroupProfile) + case memberAccepted(groupMemberId: Int64, profile: Profile) case userPendingReview var text: String { @@ -4644,6 +4646,7 @@ public enum SndGroupEvent: Decodable, Hashable { return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName) case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item") + case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item") case .userPendingReview: return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item") } 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 3ac13a943a..d5344f5aa3 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 @@ -2572,7 +2572,7 @@ data class ChatItem ( else -> CIMergeCategory.RcvGroupEvent } is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { - is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.UserPendingReview -> null + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null else -> CIMergeCategory.SndGroupEvent } else -> { @@ -4358,6 +4358,7 @@ sealed class SndGroupEvent() { @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent() @Serializable @SerialName("groupUpdated") class GroupUpdated(val groupProfile: GroupProfile): SndGroupEvent() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent() val text: String get() = when (this) { @@ -4371,6 +4372,7 @@ sealed class SndGroupEvent() { is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName) is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left) is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated) + is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted) is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review) } } 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 acd57f7ffc..e89bd40299 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1600,6 +1600,7 @@ you removed %1$s you left group profile updated + you accepted this member Please wait for group moderators to review your request to join the group. %s connected diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c5006bf530..656191d0b5 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2062,6 +2062,11 @@ processChatCommand' vr = \case m' <- updateGroupMemberAccepted db user m GSMemConnected role gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' pure (m', gInfo') + -- create item in both scopes + createInternalChatItem user (CDGroupRcv gInfo' Nothing m') (CIRcvGroupEvent RGEMemberConnected) Nothing + let scopeInfo = Just GCSIMemberSupport {groupMember_ = Just m'} + gEvent = SGEMemberAccepted gmId (fromLocalProfile $ memberProfile m') + createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent gEvent) Nothing pure $ CRMemberAccepted user gInfo' m' Nothing -> throwChatError CEGroupMemberNotActive GSMemPendingReview -> do @@ -2078,6 +2083,11 @@ processChatCommand' vr = \case m' <- updateGroupMemberAccepted db user m newMemberStatus role gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' pure (m', gInfo') + -- create item in both scopes + createInternalChatItem user (CDGroupRcv gInfo' Nothing m') (CIRcvGroupEvent RGEMemberConnected) Nothing + let scopeInfo = Just GCSIMemberSupport {groupMember_ = Just m'} + gEvent = SGEMemberAccepted gmId (fromLocalProfile $ memberProfile m') + createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent gEvent) Nothing pure $ CRMemberAccepted user gInfo' m' where newMemberStatus = case memberConn m of diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index d978ae7bc1..16b6ac3cd5 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2104,6 +2104,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = gInfo' <- updateGroupMembersRequireAttention db user gInfo referencedMember referencedMember' pure (referencedMember', gInfo') when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' + -- create item in both scopes + memberConnectedChatItem gInfo' Nothing referencedMember' let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Just referencedMember'} gEvent = RGEMemberAccepted (groupMemberId' referencedMember') (fromLocalProfile $ memberProfile referencedMember') (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent gEvent) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 0548af9d73..cc2f97ac44 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -343,6 +343,7 @@ sndGroupEventToText = \case SGEMemberDeleted _ p -> "removed " <> profileToText p SGEUserLeft -> "left" SGEGroupUpdated _ -> "group profile updated" + SGEMemberAccepted _ _p -> "you accepted this member" SGEUserPendingReview -> "please wait for group moderators to review your request to join the group" -- used to send to members with old version diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 3cc7620a94..539c1f524c 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -41,6 +41,7 @@ data SndGroupEvent | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMembers | SGEUserLeft -- CRLeftMemberUser | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated + | SGEMemberAccepted {groupMemberId :: GroupMemberId, profile :: Profile} | SGEUserPendingReview deriving (Show) From b81e5b13b30a3dabc0ab1c7a61dc5c5616200eaf Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 13 May 2025 14:01:53 +0000 Subject: [PATCH 31/72] ui: fix messages being sent in wrong scope after receiving an item in support scope (#5903) --- apps/ios/Shared/Model/ChatModel.swift | 6 +++++- .../kotlin/chat/simplex/common/model/ChatModel.kt | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3588ec8db4..4d3fd0808d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -499,7 +499,11 @@ final class ChatModel: ObservableObject { func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { - chats[i].chatInfo = cInfo + if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { + chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil) + } else { + chats[i].chatInfo = cInfo + } chats[i].created = Date.now } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index d5344f5aa3..37c37cd211 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 @@ -355,6 +355,8 @@ object ChatModel { ) ) } + } else if (currentCInfo is ChatInfo.Group && newCInfo is ChatInfo.Group && newCInfo.groupChatScope != null) { + newCInfo = newCInfo.copy(groupInfo = newCInfo.groupInfo, groupChatScope = null) } chats[i] = chats[i].copy(chatInfo = newCInfo) } From e29a9371b6fb71905e7f36ba51ce55a54150d877 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 13 May 2025 16:29:47 +0000 Subject: [PATCH 32/72] core, kotlin: open support chat with any member (#5904) --- .../views/chat/group/GroupChatInfoView.kt | 5 +++- .../views/chat/group/GroupMemberInfoView.kt | 9 ++++--- src/Simplex/Chat/Store/Messages.hs | 26 ++++++++++++++----- tests/ChatTests/Groups.hs | 5 ++-- 4 files changed, 31 insertions(+), 14 deletions(-) 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 006456004b..ee0d0e33cd 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 @@ -483,7 +483,10 @@ fun ModalData.GroupChatInfoLayout( } } } - if (groupInfo.membership.supportChat != null) { + if ( + groupInfo.membership.memberActive && + (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) + ) { anyTopSectionRowShow = true UserSupportChatButton(groupInfo, scrollToItemId) } 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 7a3bd60e14..3a0cb5a3bf 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 @@ -439,10 +439,11 @@ fun GroupMemberInfoLayout( if (member.memberActive) { SectionView { - // TODO [knocking] allow creating support chat with members that don't have it (currently doesn't work) - // groupInfo.membership.memberRole >= GroupMemberRole.Moderator && - // (member.memberRole <= GroupMemberRole.Moderator || member.supportChat != null) - if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator && member.supportChat != null) { + if ( + groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + member.memberActive && + (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) + ) { SupportChatButton() } if (connectionCode != null) { diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 6331a3daf6..d6affc4f12 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -150,7 +150,7 @@ import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe) +import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Ord (Down (..), comparing) import Data.Text (Text) import qualified Data.Text as T @@ -1352,7 +1352,7 @@ getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe Grou getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId - scopeInfo <- mapM (getGroupChatScopeInfo db vr user g) scope_ + scopeInfo <- mapM (getCreateGroupChatScopeInfo db vr 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 @@ -1362,6 +1362,24 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do unless (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 + 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 + 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) + getGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo getGroupChatScopeInfo db vr user GroupInfo {membership} = \case GCSMemberSupport Nothing -> case supportChat membership of @@ -1373,10 +1391,6 @@ getGroupChatScopeInfo db vr user GroupInfo {membership} = \case Nothing -> throwError $ SEInternalError "no support chat" Just _supportChat -> 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) - getGroupChatScopeForItem_ :: DB.Connection -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScope) getGroupChatScopeForItem_ db itemId = ExceptT . firstRow toScope (SEChatItemNotFound itemId) $ diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9b55b7cb37..ea83a10cba 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6969,8 +6969,7 @@ testScopedSupportManyModerators = dan #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (0, "2")]) dan #$> ("/_get chat #1(_support:3) count=100", chat, [(0, "3"), (0, "4"), (1, "5")]) cath #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (0, "2")]) - cath ##> "/_get chat #1(_support:3) count=100" - cath <## "chat db error: SEInternalError {message = \"no support chat\"}" + cath #$> ("/_get chat #1(_support:3) count=100", chat, []) alice ##> "/member support chats #team" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" @@ -6979,7 +6978,7 @@ testScopedSupportManyModerators = bob ##> "/member support chats #team" bob <## "support: unread: 0, require attention: 0, mentions: 0" cath ##> "/member support chats #team" - cath TestParams -> IO () testScopedSupportUnreadStats = From 22b022149ca724d6fcb47b83f5b8c879c6919db6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 14 May 2025 06:44:21 +0000 Subject: [PATCH 33/72] android, desktop: rename chat with member button (#5907) --- .../simplex/common/views/chat/group/GroupMemberInfoView.kt | 2 +- .../common/src/commonMain/resources/MR/base/strings.xml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 3a0cb5a3bf..7e75edac67 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 @@ -308,7 +308,7 @@ fun GroupMemberInfoLayout( SettingsActionItem( painterResource(MR.images.ic_flag), - stringResource(MR.strings.button_support_chat), + stringResource(MR.strings.button_support_chat_member), click = { val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member) val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) 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 e89bd40299..d7785112f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1733,6 +1733,7 @@ Receipts are disabled This group has over %1$d members, delivery receipts are not sent. Invite + Chat with admins FOR CONSOLE @@ -1769,7 +1770,7 @@ Remove member? Remove members? Remove member - Chat with admins + Chat with member Send direct message Member will be removed from group - this cannot be undone! Members will be removed from group - this cannot be undone! From 9478172a82e19ddc611e4a0d9510453f01eab1d9 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 14 May 2025 10:36:54 +0100 Subject: [PATCH 34/72] ios: fix toolbar in member support chat (#5902) * ios: fix toolbar in member support chat * refactor * refactor nav buttons * ios: unread reports, members requiring attention * top bar destop (special case for 1 support chat) * put Navigation view higher in hierarchy * use nav title for knocking * refactor loading secondary chat, overlay fake toolbar for knocking chats * fix member reports toolbar * unread counter for member's support chat --- apps/ios/Shared/Model/ChatModel.swift | 9 +- apps/ios/Shared/Views/Chat/ChatView.swift | 445 +++++++++--------- .../Views/Chat/Group/GroupChatInfoView.swift | 106 ++--- .../Chat/Group/MemberAdmissionView.swift | 1 + .../Chat/Group/MemberSupportChatToolbar.swift | 2 +- .../Views/Chat/Group/MemberSupportView.swift | 18 +- .../Views/Chat/Group/SecondaryChatView.swift | 25 +- .../Views/ChatList/ChatListNavLink.swift | 10 +- .../Shared/Views/ChatList/ChatListView.swift | 3 +- .../Shared/Views/ChatList/UserPicker.swift | 18 +- apps/ios/Shared/Views/Helpers/AppSheet.swift | 4 +- .../Views/UserSettings/UserProfilesView.swift | 2 +- .../simplex/common/views/chat/ChatView.kt | 9 +- .../commonMain/resources/MR/base/strings.xml | 3 +- 14 files changed, 328 insertions(+), 327 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4d3fd0808d..cdf3ad05a9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -103,6 +103,12 @@ class ItemsModel: ObservableObject { .store(in: &bag) } + static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) { + let im = ItemsModel(secondaryIMFilter: chatFilter) + ChatModel.shared.secondaryIM = im + im.loadOpenChat(chatId, willNavigate: willNavigate) + } + func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -111,9 +117,6 @@ class ItemsModel: ObservableObject { try await Task.sleep(nanoseconds: 250_000000) await MainActor.run { ChatModel.shared.chatId = chatId - if secondaryIMFilter != nil { - ChatModel.shared.secondaryIM = self - } willNavigate() } } catch {} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 6025314907..99aeafefe2 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -25,7 +25,6 @@ struct ChatView: View { @ObservedObject var im: ItemsModel @State var mergedItems: BoxedValue @State var floatingButtonModel: FloatingButtonModel - var onSheet: Bool @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -77,6 +76,13 @@ struct ChatView: View { private var viewBody: some View { let cInfo = chat.chatInfo + let memberSupportChat: (groupInfo: GroupInfo, member: GroupMember?)? = + if case let .group(groupInfo, .memberSupport(member)) = cInfo { + (groupInfo, member) + } else { + nil + } + let userMemberKnockingChat = memberSupportChat?.groupInfo.membership.memberPending == true return ZStack { let wallpaperImage = theme.wallpaper.type.image let wallpaperType = theme.wallpaper.type @@ -89,11 +95,15 @@ struct ChatView: View { ) } VStack(spacing: 0) { - if onSheet { - customUserSupportChatNavigationBar() - } ZStack(alignment: .bottomTrailing) { - chatItemsList() + if userMemberKnockingChat { + ZStack(alignment: .top) { + chatItemsList() + userMemberKnockingTitleBar() + } + } else { + chatItemsList() + } if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { GroupMentionsView(im: im, groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) } @@ -146,7 +156,11 @@ struct ChatView: View { } .background(ToolbarMaterial.material(toolbarMaterial)) } - .navigationTitle(cInfo.chatViewName) + .navigationTitle( + memberSupportChat == nil + ? cInfo.chatViewName + : memberSupportChat?.member?.chatViewName ?? NSLocalizedString("Chat with admins", comment: "chat toolbar") + ) .background(theme.colors.background) .navigationBarTitleDisplayMode(.inline) .environmentObject(theme) @@ -179,23 +193,19 @@ struct ChatView: View { } } .appSheet(item: $selectedMember) { member in - Group { - if case let .group(groupInfo, _) = chat.chatInfo { - GroupMemberInfoView( - groupInfo: groupInfo, - chat: chat, - groupMember: member, - navigation: true - ) - } + if case let .group(groupInfo, _) = chat.chatInfo { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: member, + navigation: true + ) } } // it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field .appSheet(isPresented: $showAddMembersSheet) { - Group { - if case let .group(groupInfo, _) = cInfo { - AddGroupMembersView(chat: chat, groupInfo: groupInfo) - } + if case let .group(groupInfo, _) = cInfo { + AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } .sheet(isPresented: Binding( @@ -214,6 +224,18 @@ struct ChatView: View { ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState) } } + .appSheet( + isPresented: $showUserSupportChatSheet, + onDismiss: { + if chat.chatInfo.groupInfo?.membership.memberPending ?? false { + chatModel.chatId = nil + } + } + ) { + if let groupInfo = cInfo.groupInfo { + SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats())) + } + } .onAppear { scrollView.listState.onUpdateListener = onChatItemsUpdated selectedChatItems = nil @@ -229,12 +251,12 @@ struct ChatView: View { } } } + // if this is the main chat of the group with the pending member (knocking) if case let .group(groupInfo, nil) = chat.chatInfo, groupInfo.membership.memberPending { - let secIM = ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) - secIM.loadOpenChat(chat.id) { - chatModel.secondaryPendingInviteeChatOpened = true + ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) { showUserSupportChatSheet = true + chatModel.secondaryPendingInviteeChatOpened = true } } } @@ -311,213 +333,207 @@ struct ChatView: View { .onChange(of: colorScheme) { _ in theme = buildTheme() } - .if(im.secondaryIMFilter == nil) { - $0.toolbar { - ToolbarItem(placement: .principal) { - if selectedChatItems != nil { - SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) - } else if case let .direct(contact) = cInfo { - Button { - Task { - showChatInfoSheet = true - } - } label: { - ChatInfoToolbar(chat: chat) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - ChatInfoView( - chat: chat, - contact: contact, - localAlias: chat.chatInfo.localAlias, - featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - onSearch: { focusSearch() } - ) - } - } else if case let .group(groupInfo, _) = cInfo { - Button { - Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } - } label: { - ChatInfoToolbar(chat: chat) - .tint(theme.colors.primary) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - GroupChatInfoView( - chat: chat, - groupInfo: Binding( - get: { groupInfo }, - set: { gInfo in - chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil) - chat.created = Date.now - } - ), - onSearch: { focusSearch() }, - localAlias: groupInfo.localAlias - ) - } - .appSheet( - isPresented: $showUserSupportChatSheet, - onDismiss: { - if chat.chatInfo.groupInfo?.membership.memberPending ?? false { - chatModel.chatId = nil - } - } - ) { - userSupportChat(groupInfo) - } - } else if case .local = cInfo { - ChatInfoToolbar(chat: chat) - } - } - ToolbarItem(placement: .navigationBarTrailing) { - if selectedChatItems != nil { - Button { - withAnimation { - selectedChatItems = nil - } - } label: { - Text("Cancel") - } - } else { - switch cInfo { - case let .direct(contact): - HStack { - let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } - } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") - } - .disabled(!contact.ready || !contact.active) - } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) - } label: { - Image(systemName: "ellipsis") - } - } - case let .group(groupInfo, _): - HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } - } else { - addMembersButton() - } - } - Menu { - searchButton() - ToggleNtfsButton(chat: chat) - } label: { - Image(systemName: "ellipsis") - } - } - case .local: - searchButton() - default: - EmptyView() - } - } + .toolbar { + ToolbarItem(placement: .principal) { + if im.secondaryIMFilter == nil { + primaryPrincipalToolbarContent() + } else if !userMemberKnockingChat { // no toolbar while knocking chat, it's unstable on sheet + secondaryPrincipalToolbarContent() } } - } - .if(im.secondaryIMFilter != nil) { - $0.toolbar { - ToolbarItem(placement: .principal) { - if selectedChatItems != nil { - SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) - } else { - switch im.secondaryIMFilter { - case let .groupChatScopeContext(groupScopeInfo): - switch groupScopeInfo { - case let .memberSupport(groupMember_): - if let groupMember = groupMember_ { - MemberSupportChatToolbar(groupMember: groupMember) - } else { - textChatToolbar("Chat with admins") - } - } - case let .msgContentTagContext(contentTag): - switch contentTag { - case .report: - textChatToolbar("Member reports") - default: - EmptyView() - } - case .none: - EmptyView() - } - } - } - ToolbarItem(placement: .navigationBarTrailing) { - if selectedChatItems != nil { - Button { - withAnimation { - selectedChatItems = nil - } - } label: { - Text("Cancel") - } - } else { - searchButton() - } + ToolbarItem(placement: .navigationBarTrailing) { + if im.secondaryIMFilter == nil { + primaryTrailingToolbarContent() + } else if !userMemberKnockingChat { + secondaryTrailingToolbarContent() } } } } - @ViewBuilder private func userSupportChat(_ groupInfo: GroupInfo) -> some View { - if let secondaryIM = chatModel.secondaryIM { - SecondaryChatView( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats()), - im: secondaryIM, - onSheet: true - ) + @inline(__always) + @ViewBuilder private func primaryPrincipalToolbarContent() -> some View { + let cInfo = chat.chatInfo + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else if case let .direct(contact) = cInfo { + Button { + Task { + showChatInfoSheet = true + } + } label: { + ChatInfoToolbar(chat: chat) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + ChatInfoView( + chat: chat, + contact: contact, + localAlias: chat.chatInfo.localAlias, + featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + onSearch: { focusSearch() } + ) + } + } else if case let .group(groupInfo, _) = cInfo { + Button { + Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } + } label: { + ChatInfoToolbar(chat: chat) + .tint(theme.colors.primary) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + GroupChatInfoView( + chat: chat, + groupInfo: Binding( + get: { groupInfo }, + set: { gInfo in + chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil) + chat.created = Date.now + } + ), + onSearch: { focusSearch() }, + localAlias: groupInfo.localAlias + ) + } + } else if case .local = cInfo { + ChatInfoToolbar(chat: chat) + } + } + + @inline(__always) + @ViewBuilder private func primaryTrailingToolbarContent() -> some View { + let cInfo = chat.chatInfo + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } } else { - EmptyView() + switch cInfo { + case let .direct(contact): + HStack { + let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + if callsPrefEnabled { + if chatModel.activeCall == nil { + callButton(contact, .audio, imageName: "phone") + .disabled(!contact.ready || !contact.active) + } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } + } + Menu { + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") + } + .disabled(!contact.ready || !contact.active) + } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) + } label: { + Image(systemName: "ellipsis") + } + } + case let .group(groupInfo, _): + HStack { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + } + } + Menu { + searchButton() + ToggleNtfsButton(chat: chat) + } label: { + Image(systemName: "ellipsis") + } + } + case .local: + searchButton() + default: + EmptyView() + } } } - private func customUserSupportChatNavigationBar() -> some View { - VStack(spacing: 0) { - HStack { - Text("Chat with admins") - .font(.headline) - .foregroundColor(theme.colors.onBackground) + @inline(__always) + @ViewBuilder private func secondaryPrincipalToolbarContent() -> some View { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else { + switch im.secondaryIMFilter { + case let .groupChatScopeContext(groupScopeInfo): + switch groupScopeInfo { + case let .memberSupport(groupMember_): + if let groupMember = groupMember_ { + MemberSupportChatToolbar(groupMember: groupMember) + } else { + textChatToolbar("Chat with admins") + } + } + case let .msgContentTagContext(contentTag): + switch contentTag { + case .report: + textChatToolbar("Member reports") + default: + EmptyView() + } + case .none: + EmptyView() } - .padding(.horizontal) - .padding(.vertical, 12) - .frame(maxWidth: .infinity) - .background(.thinMaterial) - Divider() } } + @inline(__always) + @ViewBuilder private func secondaryTrailingToolbarContent() -> some View { + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } + } else { + searchButton() + } + } + + @inline(__always) + private func userMemberKnockingTitleBar() -> some View { + VStack(spacing: 0) { + Text("Chat with admins") + .font(.headline) + .foregroundColor(theme.colors.onBackground) + .padding(.top, 8) + .padding(.bottom, 14) + .frame(maxWidth: .infinity) + .background(ToolbarMaterial.material(toolbarMaterial)) + Divider() + } + } + func textChatToolbar(_ text: LocalizedStringKey) -> some View { - HStack { - Text(text).font(.headline) - .lineLimit(1) - } + Text(text) + .font(.headline) + .lineLimit(1) .foregroundColor(theme.colors.onBackground) .frame(width: 220) } @@ -2652,8 +2668,7 @@ struct ChatView_Previews: PreviewProvider { chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im), - onSheet: false + floatingButtonModel: FloatingButtonModel(im: im) ) .environmentObject(chatModel) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0411935d64..622213c946 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -96,17 +96,10 @@ struct GroupChatInfoView: View { memberSupportButton() } if groupInfo.canModerate { - GroupReportsChatNavLink( - chat: chat, - im: ItemsModel(secondaryIMFilter: .msgContentTagContext(contentTag: .report)) - ) + GroupReportsChatNavLink(chat: chat) } if groupInfo.membership.supportChat != nil { - let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) - UserSupportChatNavLink( - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), - im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) - ) + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo) } } header: { Text("") @@ -535,34 +528,29 @@ struct GroupChatInfoView: View { } struct UserSupportChatNavLink: View { - @EnvironmentObject var chatModel: ChatModel - @State private var userSupportChatNavLinkActive = false @ObservedObject var chat: Chat - var im: ItemsModel + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @State private var navLinkActive = false var body: some View { - ZStack { - Button { - im.loadOpenChat(chat.id) { - userSupportChatNavLinkActive = true + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + NavigationLink(isActive: $navLinkActive) { + SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) + } label: { + HStack { + Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.filled" : "flag") + Spacer() + if chat.supportUnreadCount > 0 { + UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) } - } label: { - Label("Chat with admins", systemImage: "flag") } - - NavigationLink(isActive: $userSupportChatNavLinkActive) { - if let secondaryIM = chatModel.secondaryIM { - SecondaryChatView( - chat: chat, - im: secondaryIM, - onSheet: false - ) - } - } label: { - EmptyView() + } + .onChange(of: navLinkActive) { active in + if active { + ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) } - .frame(width: 1, height: 1) - .hidden() } } } @@ -574,47 +562,45 @@ struct GroupChatInfoView: View { .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.large) } label: { - Label( - "Chats with members", - systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag" - ) + HStack { + Label( + "Chats with members", + systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag" + ) + Spacer() + if chat.supportUnreadCount > 0 { + UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) + } + } } } struct GroupReportsChatNavLink: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme - @State private var groupReportsChatNavLinkActive = false + @State private var navLinkActive = false @ObservedObject var chat: Chat - var im: ItemsModel var body: some View { - ZStack { - Button { - im.loadOpenChat(chat.id) { - groupReportsChatNavLinkActive = true + NavigationLink(isActive: $navLinkActive) { + SecondaryChatView(chat: chat) + } label: { + HStack { + Label { + Text("Member reports") + } icon: { + Image(systemName: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag").foregroundColor(.red) } - } label: { - Label( - "Member reports", - systemImage: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag" - ) - .foregroundColor(chat.chatStats.reportsCount > 0 ? .red : theme.colors.primary) - } - - NavigationLink(isActive: $groupReportsChatNavLinkActive) { - if let secondaryIM = chatModel.secondaryIM { - SecondaryChatView( - chat: chat, - im: secondaryIM, - onSheet: false - ) + Spacer() + if chat.chatStats.reportsCount > 0 { + UnreadBadge(count: chat.chatStats.reportsCount, color: .red) } - } label: { - EmptyView() } - .frame(width: 1, height: 1) - .hidden() + } + .onChange(of: navLinkActive) { active in + if active { + ItemsModel.loadSecondaryChat(chat.id, chatFilter: .msgContentTagContext(contentTag: .report)) + } } } } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift index e1db2c35ad..d80615b5d2 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift @@ -71,6 +71,7 @@ struct MemberAdmissionView: View { Text(mc.text) } } + .frame(height: 36) } else { infoRow(Text(admissionStageStr), memberCriteria.wrappedValue?.text ?? NSLocalizedString("off", comment: "member criteria value")) } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift index ef43950897..23001e64bf 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -19,7 +19,7 @@ struct MemberSupportChatToolbar: View { return HStack { MemberProfileImage(groupMember, size: imageSize) .padding(.trailing, 4) - let t = Text(groupMember.displayName).font(.headline) + let t = Text(groupMember.chatViewName).font(.headline) (groupMember.verified ? memberVerifiedShield + t : t) .lineLimit(1) } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 587647edea..3bd2909b2d 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -51,12 +51,9 @@ struct MemberSupportView: View { searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) .padding(.leading, 8) ForEach(filteredMembersWithChats) { memberWithChat in - let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped) MemberSupportChatNavLink( groupInfo: groupInfo, - memberWithChat: memberWithChat, - chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), - im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + memberWithChat: memberWithChat ) } } @@ -69,13 +66,12 @@ struct MemberSupportView: View { @State private var memberSupportChatNavLinkActive = false var groupInfo: GroupInfo var memberWithChat: GMember - @ObservedObject var chat: Chat - var im: ItemsModel var body: some View { ZStack { + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped) Button { - im.loadOpenChat(chat.id) { + ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) { memberSupportChatNavLinkActive = true } } label: { @@ -83,13 +79,7 @@ struct MemberSupportView: View { } NavigationLink(isActive: $memberSupportChatNavLinkActive) { - if let secondaryIM = chatModel.secondaryIM { - SecondaryChatView( - chat: chat, - im: secondaryIM, - onSheet: false - ) - } + SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) } label: { EmptyView() } diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift index 13b3252cf4..220f8f81d2 100644 --- a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -12,19 +12,18 @@ import SimpleXChat struct SecondaryChatView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat - @ObservedObject var im: ItemsModel - var onSheet: Bool var body: some View { - ChatView( - chat: chat, - im: im, - mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im), - onSheet: onSheet - ) - .onDisappear { - chatModel.secondaryIM = nil + if let im = chatModel.secondaryIM { + ChatView( + chat: chat, + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im) + ) + .onDisappear { + chatModel.secondaryIM = nil + } } } } @@ -35,8 +34,6 @@ struct SecondaryChatView: View { chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), chatItems: [], chatStats: ChatStats() - ), - im: ItemsModel.shared, - onSheet: false + ) ) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 9c3b25b755..f6a6cac8c5 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -482,12 +482,10 @@ struct ChatListNavLink: View { } .frame(height: dynamicRowHeight) .appSheet(isPresented: $showContactConnectionInfo) { - Group { - if case let .contactConnection(contactConnection) = chat.chatInfo { - ContactConnectionInfo(contactConnection: contactConnection) - .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) - .modifier(ThemedBackground(grouped: true)) - } + if case let .contactConnection(contactConnection) = chat.chatInfo { + ContactConnectionInfo(contactConnection: contactConnection) + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + .modifier(ThemedBackground(grouped: true)) } } .contentShape(Rectangle()) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d550017a3b..cd5c5624c9 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -457,8 +457,7 @@ struct ChatListView: View { chat: chat, im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im), - onSheet: false + floatingButtonModel: FloatingButtonModel(im: im) ) } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index dbe10ad997..c38ddfb1da 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -124,7 +124,7 @@ struct UserPicker: View { ZStack(alignment: .topTrailing) { ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) if (u.unreadCount > 0) { - UnreadBadge(userInfo: u).offset(x: 4, y: -4) + userUnreadBadge(u, theme: theme).offset(x: 4, y: -4) } } .padding(.trailing, 6) @@ -171,19 +171,27 @@ struct UserPicker: View { } } +@inline(__always) +func userUnreadBadge(_ userInfo: UserInfo, theme: AppTheme) -> some View { + UnreadBadge( + count: userInfo.unreadCount, + color: userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary + ) +} + struct UnreadBadge: View { - var userInfo: UserInfo - @EnvironmentObject var theme: AppTheme @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var count: Int + var color: Color var body: some View { let size = dynamicSize(userFont).chatInfoSize - unreadCountText(userInfo.unreadCount) + unreadCountText(count) .font(userFont <= .xxxLarge ? .caption : .caption2) .foregroundColor(.white) .padding(.horizontal, dynamicSize(userFont).unreadPadding) .frame(minWidth: size, minHeight: size) - .background(userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary) + .background(color) .cornerRadius(dynamicSize(userFont).unreadCorner) } } diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 1e334367e8..17fe95a058 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -33,7 +33,7 @@ extension View { func appSheet( isPresented: Binding, onDismiss: (() -> Void)? = nil, - content: @escaping () -> Content + @ViewBuilder content: @escaping () -> Content ) -> some View where Content: View { sheet(isPresented: isPresented, onDismiss: onDismiss) { content().modifier(PrivacySensitive()) @@ -43,7 +43,7 @@ extension View { func appSheet( item: Binding, onDismiss: (() -> Void)? = nil, - content: @escaping (T) -> Content + @ViewBuilder content: @escaping (T) -> Content ) -> some View where T: Identifiable, Content: View { sheet(item: item, onDismiss: onDismiss) { it in content(it).modifier(PrivacySensitive()) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 887023b670..ddfe59e719 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -350,7 +350,7 @@ struct UserProfilesView: View { Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground) } else { if userInfo.unreadCount > 0 { - UnreadBadge(userInfo: userInfo) + userUnreadBadge(userInfo, theme: theme) } if user.hidden { Image(systemName: "lock").foregroundColor(theme.colors.secondary) 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 afe355a9be..e2c4eb7d83 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 @@ -1252,13 +1252,16 @@ private fun SupportChatsCountToolbar( Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.primary) Spacer(Modifier.width(4.dp)) Text( - if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) { if (appPlatform.isAndroid) stringResource(MR.strings.group_new_support_chats_short).format(supportUnreadCount) + else if (supportUnreadCount == 1) + stringResource(MR.strings.group_new_support_chat_one) else stringResource(MR.strings.group_new_support_chats).format(supportUnreadCount) - else - stringResource(MR.strings.group_new_support_messages).format(supportUnreadCount), + } else { + stringResource(MR.strings.group_new_support_messages).format(supportUnreadCount) + }, style = MaterialTheme.typography.button ) } 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 d7785112f1..bd450d7d3c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -467,7 +467,8 @@ Member reports %d messages %d chats with members - %d chats + 1 chat with a member + %d chat(s) Share message… From d3bc22726a0e282422a3fad976e18a5b7011b99c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 14 May 2025 11:25:24 +0000 Subject: [PATCH 35/72] core: fix group being displayed as connecting after join (#5908) --- src/Simplex/Chat/Library/Subscriber.hs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 16b6ac3cd5..f9d242ff27 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -769,20 +769,22 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do - (mStatus, membershipStatus) <- withStore' $ \db -> do + (m', gInfo') <- withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected - membershipStatus <- + gInfo' <- if not (memberPending membership) - then updateGroupMemberStatus db userId membership GSMemConnected $> GSMemConnected - else pure $ memberStatus membership - pure (GSMemConnected, membershipStatus) - toView $ CEvtUserJoinedGroup user gInfo {membership = membership {memberStatus = membershipStatus}} m {memberStatus = mStatus} - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - let cd = CDGroupRcv gInfo' scopeInfo m' + then do + updateGroupMemberStatus db userId membership GSMemConnected + pure gInfo {membership = membership {memberStatus = GSMemConnected}} + else pure gInfo + pure (m {memberStatus = GSMemConnected}, gInfo') + toView $ CEvtUserJoinedGroup user gInfo' m' + (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' + let cd = CDGroupRcv gInfo'' scopeInfo m'' createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing - createGroupFeatureItems user cd CIRcvGroupFeature gInfo' - memberConnectedChatItem gInfo' scopeInfo m' - unless (memberPending membership) $ maybeCreateGroupDescrLocal gInfo' m' + createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' + memberConnectedChatItem gInfo'' scopeInfo m'' + unless (memberPending membership) $ maybeCreateGroupDescrLocal gInfo'' m'' GCInviteeMember -> do (gInfo', mStatus) <- if not (memberPending m) From b97e1e0f119c484156a6f113e8a7f1e7c570a83c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 14 May 2025 15:14:34 +0000 Subject: [PATCH 36/72] ui: show new messages from support scope in main chat preview when invitee is pending (#5909) --- apps/ios/Shared/Model/ChatModel.swift | 2 +- .../Chat/ComposeMessage/ComposeView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 24 ++++++------------- .../platform/PlatformTextField.android.kt | 2 +- .../chat/simplex/common/model/ChatModel.kt | 20 ++++++++++------ .../common/views/chatlist/ChatPreviewView.kt | 24 +++++++------------ .../commonMain/resources/MR/base/strings.xml | 2 +- .../platform/PlatformTextField.desktop.kt | 2 +- src/Simplex/Chat/Library/Subscriber.hs | 20 +++++++++------- 10 files changed, 45 insertions(+), 55 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index cdf3ad05a9..23a50ff07b 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -582,7 +582,7 @@ final class ChatModel: ObservableObject { // update chat list if let i = getChatIndex(cInfo.id) { // update preview - if cInfo.groupChatScope() == nil { + if cInfo.groupChatScope() == nil || cInfo.groupInfo?.membership.memberPending ?? false { chats[i].chatItems = switch cInfo { case .group: if let currentPreviewItem = chats[i].chatItems.first { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index aec160298e..e4d1fb5a66 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -460,7 +460,7 @@ struct ComposeView: View { if im.secondaryIMFilter == nil { if chat.userIsPending { - Text("reviewed by moderators") + Text("reviewed by admins") .italic() .foregroundColor(theme.colors.secondary) .padding(.horizontal, 12) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 622213c946..91c70f5d57 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -540,7 +540,7 @@ struct GroupChatInfoView: View { SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) } label: { HStack { - Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.filled" : "flag") + Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag") Spacer() if chat.supportUnreadCount > 0 { UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index d0a6e77566..49f629d084 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -346,6 +346,7 @@ struct ChatPreviewView: View { case .memRejected: chatPreviewInfoText("rejected") case .memInvited: groupInvitationPreviewText(groupInfo) case .memAccepted: chatPreviewInfoText("connecting…") + case .memPendingReview, .memPendingApproval: chatPreviewInfoText("reviewed by admins") default: EmptyView() } default: EmptyView() @@ -439,9 +440,11 @@ struct ChatPreviewView: View { if progressByTimeout { ProgressView() } else if chat.chatStats.reportsCount > 0 { - groupReportsIcon(size: size * 0.8) + flagIcon(size: size * 0.8, color: .red) } else if chat.supportUnreadCount > 0 { - GroupSupportUnreadIcon(size: size * 0.8) + flagIcon(size: size * 0.8, color: theme.colors.primary) + } else if chat.chatInfo.groupInfo?.membership.memberPending ?? false { + flagIcon(size: size * 0.8, color: theme.colors.secondary) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } @@ -487,25 +490,12 @@ struct ChatPreviewView: View { } } -func groupReportsIcon(size: CGFloat) -> some View { +func flagIcon(size: CGFloat, color: Color) -> some View { Image(systemName: "flag") .resizable() .scaledToFit() .frame(width: size, height: size) - .foregroundColor(.red) -} - -struct GroupSupportUnreadIcon: View { - @EnvironmentObject var theme: AppTheme - var size: CGFloat - - var body: some View { - Image(systemName: "flag") - .resizable() - .scaledToFit() - .frame(width: size, height: size) - .foregroundColor(theme.colors.primary) - } + .foregroundColor(color) } func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index e6038a3fd5..3263e559b7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -200,7 +200,7 @@ actual fun PlatformTextField( if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) } else if (userIsPending) { - ComposeOverlay(MR.strings.reviewed_by_moderators, textStyle, padding) + ComposeOverlay(MR.strings.reviewed_by_admins, textStyle, padding) } else if (userIsObserver) { ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) } 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 37c37cd211..f50b4a99ca 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 @@ -442,9 +442,9 @@ object ChatModel { val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { - chat = chats[i] - // update preview - if (cInfo.groupChatScope() == null) { + chat = chatsContext.chats[i] + // update preview (for chat from main scope to show new items for invitee in pending status) + if (cInfo.groupChatScope() == null || cInfo.groupInfo_?.membership?.memberPending == true) { val newPreviewItem = when (cInfo) { is ChatInfo.Group -> { val currentPreviewItem = chat.chatItems.firstOrNull() @@ -462,7 +462,7 @@ object ChatModel { else -> cItem } val wasUnread = chat.unreadTag - chats[i] = chat.copy( + chatsContext.chats[i] = chat.copy( chatItems = arrayListOf(newPreviewItem), chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { @@ -471,11 +471,11 @@ object ChatModel { } else chat.chatStats ) - updateChatTagReadInPrimaryContext(chats[i], wasUnread) + updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread) } // pop chat if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chats[i], 0) + reorderChat(chatsContext.chats[i], 0) } else { popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) } @@ -1558,7 +1558,13 @@ sealed class ChatInfo: SomeChat, NamedChat { is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active else -> false } - } + + val groupInfo_: GroupInfo? + get() = when (this) { + is Group -> groupInfo + else -> null + } +} @Serializable sealed class NetworkStatus { 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 eb7eb74103..2d375389dc 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 @@ -241,6 +241,8 @@ fun ChatPreviewView( GroupMemberStatus.MemRejected -> Text(stringResource(MR.strings.group_preview_rejected)) GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo)) GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary) + GroupMemberStatus.MemPendingReview, GroupMemberStatus.MemPendingApproval -> + Text(stringResource(MR.strings.reviewed_by_admins), color = MaterialTheme.colors.secondary) else -> {} } else -> {} @@ -363,9 +365,11 @@ fun ChatPreviewView( if (progressByTimeout) { progressView() } else if (chat.chatStats.reportsCount > 0) { - GroupReportsIcon() + FlagIcon(color = MaterialTheme.colors.error) } else if (chat.supportUnreadCount > 0) { - GroupSupportUnreadIcon() + FlagIcon(color = MaterialTheme.colors.primary) + } else if (chat.chatInfo.groupInfo_?.membership?.memberPending == true) { + FlagIcon(color = MaterialTheme.colors.secondary) } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -550,23 +554,11 @@ fun IncognitoIcon(incognito: Boolean) { } @Composable -fun GroupReportsIcon() { +fun FlagIcon(color: Color) { Icon( painterResource(MR.images.ic_flag), contentDescription = null, - tint = MaterialTheme.colors.error, - modifier = Modifier - .size(21.sp.toDp()) - .offset(x = 2.sp.toDp()) - ) -} - -@Composable -fun GroupSupportUnreadIcon() { - Icon( - painterResource(MR.images.ic_flag), - contentDescription = null, - tint = MaterialTheme.colors.primary, + tint = color, modifier = Modifier .size(21.sp.toDp()) .offset(x = 2.sp.toDp()) 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 bd450d7d3c..12016794ee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -492,7 +492,7 @@ The image cannot be decoded. Please, try a different image or contact developers. The video cannot be decoded. Please, try a different video or contact developers. you are observer - reviewed by moderators + reviewed by admins You can\'t send messages! Please contact group admin. Files and media prohibited! diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index c6b9a2f73e..03bc497699 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -206,7 +206,7 @@ actual fun PlatformTextField( if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) } else if (userIsPending) { - ComposeOverlay(MR.strings.reviewed_by_moderators, textStyle, padding) + ComposeOverlay(MR.strings.reviewed_by_admins, textStyle, padding) } else if (userIsObserver) { ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) } diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index f9d242ff27..9761c3841d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2121,19 +2121,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processUserAccepted = case acceptance of GAAccepted -> do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role - let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) + let gInfo' = gInfo {membership = membership'} + scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) groupMsgToView cInfo ci - toView $ CEvtUserJoinedGroup user gInfo {membership = membership'} m - let cd = CDGroupRcv gInfo Nothing m + toView $ CEvtUserJoinedGroup user gInfo' m + let cd = CDGroupRcv gInfo' Nothing m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing - createGroupFeatureItems user cd CIRcvGroupFeature gInfo - maybeCreateGroupDescrLocal gInfo m + createGroupFeatureItems user cd CIRcvGroupFeature gInfo' + maybeCreateGroupDescrLocal gInfo' m GAPendingReview -> do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemPendingReview role - let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} - createInternalChatItem user (CDGroupSnd gInfo scopeInfo) (CISndGroupEvent SGEUserPendingReview) Nothing - toView $ CEvtMemberAcceptedByOther user gInfo m membership' + let gInfo' = gInfo {membership = membership'} + scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndGroupEvent SGEUserPendingReview) Nothing + toView $ CEvtMemberAcceptedByOther user gInfo' m membership' GAPendingApproval -> messageWarning "x.grp.link.acpt: unexpected group acceptance - pending approval" introduceToRemainingMembers acceptedMember = do From be28022bc632b983f201d1c8bf0908cae43622a9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 14 May 2025 16:21:01 +0000 Subject: [PATCH 37/72] ios: open support chat with any member (#5910) --- .../Views/Chat/Group/GroupChatInfoView.swift | 5 ++-- .../Chat/Group/GroupMemberInfoView.swift | 25 +++++++++++++++++++ .../views/chat/group/GroupMemberInfoView.kt | 1 - 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 91c70f5d57..202970f012 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -98,7 +98,8 @@ struct GroupChatInfoView: View { if groupInfo.canModerate { GroupReportsChatNavLink(chat: chat) } - if groupInfo.membership.supportChat != nil { + if groupInfo.membership.memberActive + && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { UserSupportChatNavLink(chat: chat, groupInfo: groupInfo) } } header: { @@ -540,7 +541,7 @@ struct GroupChatInfoView: View { SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) } label: { HStack { - Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag") + Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag") Spacer() if chat.supportUnreadCount > 0 { UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index ca9c0ecce3..1e95eea2f3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -103,6 +103,10 @@ struct GroupMemberInfoView: View { if member.memberActive { Section { + if groupInfo.membership.memberRole >= .moderator + && (member.memberRole < .moderator || member.supportChat != nil) { + MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember) + } if let code = connectionCode { verifyCodeButton(code) } if let connStats = connectionStats, connStats.ratchetSyncAllowed { @@ -474,6 +478,27 @@ struct GroupMemberInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + struct MemberInfoSupportChatNavLink: View { + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + var member: GMember + @State private var navLinkActive = false + + var body: some View { + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: member.wrapped) + NavigationLink(isActive: $navLinkActive) { + SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) + } label: { + Label("Chat with member", systemImage: "flag") + } + .onChange(of: navLinkActive) { active in + if active { + ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + } + } + } + } + private func verifyCodeButton(_ code: String) -> some View { let member = groupMember.wrapped return NavigationLink { 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 7e75edac67..5db1ba93f7 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 @@ -441,7 +441,6 @@ fun GroupMemberInfoLayout( SectionView { if ( groupInfo.membership.memberRole >= GroupMemberRole.Moderator && - member.memberActive && (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) ) { SupportChatButton() From 775f6f14bfcf7122315b54e2ffa34e3c8bb8cb38 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 15 May 2025 13:42:23 +0000 Subject: [PATCH 38/72] android, desktop: fix navigation to quoted messages in support chats (#5912) --- .../chat/simplex/common/views/chat/ChatView.kt | 15 +++++++++------ .../common/views/chat/item/ChatItemView.kt | 5 ++++- .../common/views/chat/item/FramedItemView.kt | 8 +++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e2c4eb7d83..ca9f2983d4 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 @@ -1415,11 +1415,9 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { - // In secondary chat just set the itemId to scroll to so the main ChatView will handle scrolling - if (chatsCtx.contentTag == MsgContentTag.Report || chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext) return@remember { scrollToItemId.value = it } scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, scrollToItemId) } if (chatsCtx.secondaryContextFilter == null) { LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { @@ -1484,7 +1482,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -2465,7 +2463,8 @@ private fun findQuotedItemFromItem( rhId: State, chatInfo: State, scope: CoroutineScope, - scrollToItem: (Long) -> Unit + scrollToItem: (Long) -> Unit, + scrollToItemId: MutableState ): (Long) -> Unit = { itemId: Long -> scope.launch(Dispatchers.Default) { val item = apiLoadSingleMessage(chatsCtx, rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId) @@ -2477,7 +2476,11 @@ private fun findQuotedItemFromItem( chatModel.secondaryChatsContext.value?.updateChatItem(chatInfo.value, item) } if (item.quotedItem?.itemId != null) { - scrollToItem(item.quotedItem.itemId) + if (item.isReport && chatsCtx.secondaryContextFilter != null) { + scrollToItemId.value = item.quotedItem.itemId + } else { + scrollToItem(item.quotedItem.itemId) + } } else { showQuotedItemDoesNotExistAlert() } 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 f523c8ca30..6e938aa5c4 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 @@ -86,6 +86,7 @@ fun ChatItemView( joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, + scrollToItemId: MutableState, scrollToQuotedItemFromItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -325,7 +326,7 @@ fun ChatItemView( ) { @Composable fun framedItemView() { - FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToQuotedItemFromItem) + FramedItemView(chatsCtx, cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem) } fun deleteMessageQuestionText(): String { @@ -1458,6 +1459,7 @@ fun PreviewChatItemView( joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, + scrollToItemId = remember { mutableStateOf(null) }, scrollToQuotedItemFromItem = {}, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, @@ -1508,6 +1510,7 @@ fun PreviewChatItemViewDeletedContent() { joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, + scrollToItemId = remember { mutableStateOf(null) }, scrollToQuotedItemFromItem = {}, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, 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 fd8a32af64..b2beba29e4 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 @@ -27,6 +27,7 @@ import kotlin.math.ceil @Composable fun FramedItemView( + chatsCtx: ChatModel.ChatsContext, chatInfo: ChatInfo, ci: ChatItem, uriHandler: UriHandler? = null, @@ -39,6 +40,7 @@ fun FramedItemView( receiveFile: (Long) -> Unit, onLinkLongClick: (link: String) -> Unit = {}, scrollToItem: (Long) -> Unit = {}, + scrollToItemId: MutableState, scrollToQuotedItemFromItem: (Long) -> Unit = {}, ) { val sent = ci.chatDir.sent @@ -253,7 +255,11 @@ fun FramedItemView( onLongClick = { showMenu.value = true }, onClick = { if (ci.quotedItem.itemId != null) { - scrollToItem(ci.quotedItem.itemId) + if (ci.isReport && chatsCtx.secondaryContextFilter != null) { + scrollToItemId.value = ci.quotedItem.itemId + } else { + scrollToItem(ci.quotedItem.itemId) + } } else { scrollToQuotedItemFromItem(ci.id) } From b32c052f136c570ed3e866117a68d967a73b7a4d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 15 May 2025 13:59:12 +0000 Subject: [PATCH 39/72] ios: reports fixes - don't create in main chat, show notice "view in chat with admins" (#5911) --- .../Chat/ComposeMessage/ComposeView.swift | 28 ++++++++++++++++--- .../Views/UserSettings/SettingsView.swift | 2 ++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index e4d1fb5a66..7111524118 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1035,9 +1035,9 @@ struct ComposeView: View { reportReason: reportReason, reportText: msgText ) { - await MainActor.run { - for chatItem in chatItems { - chatModel.addChatItem(chat.chatInfo, chatItem) + if showReportsInSupportChatAlertDefault.get() { + await MainActor.run { + showReportsInSupportChatAlert() } } return chatItems.first @@ -1045,7 +1045,27 @@ struct ComposeView: View { return nil } - + + func showReportsInSupportChatAlert() { + showAlert( + NSLocalizedString("Report sent to moderators", comment: "alert title"), + message: NSLocalizedString("You can view your reports in Chat with admins.", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Don't show again", comment: "alert action"), + style: .default, + handler: { _ in + showReportsInSupportChatAlertDefault.set(false) + } + ), + UIAlertAction( + title: NSLocalizedString("Ok", comment: "alert action"), + style: .default + ) + ]} + ) + } + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? { await send( [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)], diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index c44f79d3f0..50a012f4f8 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -198,6 +198,8 @@ let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.sta let showDeleteConversationNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE) let showDeleteContactNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONTACT_NOTICE) +let showReportsInSupportChatAlertDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT) + /// after importing new database, this flag will be set and unset only after importing app settings in `initializeChat` */ let shouldImportAppSettingsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_IMPORT_APP_SETTINGS) let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME) From 409d7e853268a08b754fdc0fb71b9bdb66deb40c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 15 May 2025 15:04:03 +0000 Subject: [PATCH 40/72] ios: scroll to reported item (#5915) --- .../Views/Chat/ChatItem/CIFileView.swift | 20 +++--- .../Views/Chat/ChatItem/CIImageView.swift | 4 +- .../Views/Chat/ChatItem/CIVoiceView.swift | 8 +-- .../Chat/ChatItem/FramedCIVoiceView.swift | 10 +-- .../Views/Chat/ChatItem/FramedItemView.swift | 71 ++++++++++--------- .../Chat/ChatItem/FullScreenMediaView.swift | 4 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 44 +++++++----- apps/ios/Shared/Views/Chat/ChatView.swift | 32 +++++++-- .../Views/Chat/Group/GroupChatInfoView.swift | 31 +++++--- .../Chat/Group/GroupMemberInfoView.swift | 12 +++- .../Views/Chat/Group/MemberSupportView.swift | 13 +++- .../Views/Chat/Group/SecondaryChatView.swift | 7 +- .../Shared/Views/ChatList/ChatListView.swift | 4 +- .../UserSettings/AppearanceSettings.swift | 4 +- 14 files changed, 165 insertions(+), 99 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 2710606c3f..1b9376b5db 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -294,16 +294,16 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index d30369339d..d1f49f635a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -12,7 +12,7 @@ import SimpleXChat struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem - var scrollToItemId: ((ChatItem.ID) -> Void)? = nil + var scrollToItem: ((ChatItem.ID) -> Void)? = nil var preview: UIImage? let maxWidth: CGFloat var imgWidth: CGFloat? @@ -26,7 +26,7 @@ struct CIImageView: View { if let uiImage = getLoadedImage(file) { Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } } .fullScreenCover(isPresented: $showFullScreenImage) { - FullScreenMediaView(chatItem: chatItem, scrollToItemId: scrollToItemId, image: uiImage, showView: $showFullScreenImage) + FullScreenMediaView(chatItem: chatItem, scrollToItem: scrollToItem, image: uiImage, showView: $showFullScreenImage) } .if(!smallView) { view in view.modifier(PrivacyBlur(blurred: $blurred)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 6a2aaf491d..47aee2a586 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -458,10 +458,10 @@ struct CIVoiceView_Previews: PreviewProvider { duration: 30, allowMenu: Binding.constant(true) ) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) } .previewLayout(.fixed(width: 360, height: 360)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 2378666990..0b6f249b9c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -93,11 +93,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 2fa05fc1a8..c9c9952688 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -15,7 +15,8 @@ struct FramedItemView: View { @ObservedObject var chat: Chat @ObservedObject var im: ItemsModel var chatItem: ChatItem - var scrollToItemId: (ChatItem.ID) -> Void + var scrollToItem: (ChatItem.ID) -> Void + @Binding var scrollToItemId: ChatItem.ID? var preview: UIImage? var maxWidth: CGFloat = .infinity @State var msgWidth: CGFloat = 0 @@ -57,12 +58,16 @@ struct FramedItemView: View { if let qi = chatItem.quotedItem { ciQuoteView(qi) .simultaneousGesture(TapGesture().onEnded { - if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { + if let ci = im.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { - scrollToItemId(ci.id) + scrollToItem(ci.id) } } else if let id = qi.itemId { - scrollToItemId(id) + if (chatItem.isReport && im.secondaryIMFilter != nil) { + scrollToItemId = id + } else { + scrollToItem(id) + } } else { showQuotedItemDoesNotExistAlert() } @@ -120,7 +125,7 @@ struct FramedItemView: View { } else { switch (chatItem.content.msgContent) { case let .image(text, _): - CIImageView(chatItem: chatItem, scrollToItemId: scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery) .overlay(DetermineWidth()) if text == "" && !chatItem.meta.isLive { Color.clear @@ -389,14 +394,14 @@ struct FramedItemView_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group{ - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -406,16 +411,16 @@ struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) @@ -426,16 +431,16 @@ struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index 10e5efa298..f243a83142 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -14,7 +14,7 @@ import AVKit struct FullScreenMediaView: View { @EnvironmentObject var m: ChatModel @State var chatItem: ChatItem - var scrollToItemId: ((ChatItem.ID) -> Void)? + var scrollToItem: ((ChatItem.ID) -> Void)? @State var image: UIImage? @State var player: AVPlayer? = nil @State var url: URL? = nil @@ -71,7 +71,7 @@ struct FullScreenMediaView: View { let w = abs(t.width) if t.height > 60 && t.height > w * 2 { showView = false - scrollToItemId?(chatItem.id) + scrollToItem?(chatItem.id) } else if w > 60 && w > abs(t.height) * 2 && !scrolling { let previous = t.width > 0 scrolling = true diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 73649c13d8..a412bf4452 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -45,7 +45,8 @@ struct ChatItemView: View { @Environment(\.showTimestamp) var showTimestamp: Bool @Environment(\.revealed) var revealed: Bool var chatItem: ChatItem - var scrollToItemId: (ChatItem.ID) -> Void + var scrollToItem: (ChatItem.ID) -> Void + @Binding var scrollToItemId: ChatItem.ID? var maxWidth: CGFloat = .infinity @Binding var allowMenu: Bool @@ -53,7 +54,8 @@ struct ChatItemView: View { chat: Chat, im: ItemsModel, chatItem: ChatItem, - scrollToItemId: @escaping (ChatItem.ID) -> Void, + scrollToItem: @escaping (ChatItem.ID) -> Void, + scrollToItemId: Binding = .constant(nil), showMember: Bool = false, maxWidth: CGFloat = .infinity, allowMenu: Binding = .constant(false) @@ -61,7 +63,8 @@ struct ChatItemView: View { self.chat = chat self.im = im self.chatItem = chatItem - self.scrollToItemId = scrollToItemId + self.scrollToItem = scrollToItem + _scrollToItemId = scrollToItemId self.maxWidth = maxWidth _allowMenu = allowMenu } @@ -106,7 +109,8 @@ struct ChatItemView: View { chat: chat, im: im, chatItem: chatItem, - scrollToItemId: scrollToItemId, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, preview: preview, maxWidth: maxWidth, imgWidth: adjustedMaxWidth, @@ -272,15 +276,15 @@ struct ChatItemView_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group{ - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 70)) @@ -303,7 +307,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { quotedItem: nil, file: nil ), - scrollToItemId: { _ in } + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, @@ -315,7 +320,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { quotedItem: nil, file: nil ), - scrollToItemId: { _ in } + scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, @@ -327,7 +332,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { quotedItem: nil, file: nil ), - scrollToItemId: { _ in } + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, @@ -339,7 +345,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { quotedItem: nil, file: nil ), - scrollToItemId: { _ in } + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) ChatItemView( chat: Chat.sampleData, @@ -351,7 +358,8 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { quotedItem: nil, file: nil ), - scrollToItemId: { _ in } + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) ) } .environment(\.revealed, true) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 99aeafefe2..190ca2ca40 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -25,6 +25,7 @@ struct ChatView: View { @ObservedObject var im: ItemsModel @State var mergedItems: BoxedValue @State var floatingButtonModel: FloatingButtonModel + @Binding var scrollToItemId: ChatItem.ID? @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -198,6 +199,7 @@ struct ChatView: View { groupInfo: groupInfo, chat: chat, groupMember: member, + scrollToItemId: $scrollToItemId, navigation: true ) } @@ -233,7 +235,10 @@ struct ChatView: View { } ) { if let groupInfo = cInfo.groupInfo { - SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats())) + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: userSupportScopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) } } .onAppear { @@ -349,6 +354,16 @@ struct ChatView: View { } } } + .if(im.secondaryIMFilter == nil) { v in + v.onChange(of: scrollToItemId) { itemId in + if let itemId = itemId { + dismissAllSheets(animated: false) { + scrollToItem(itemId) + scrollToItemId = nil + } + } + } + } } @inline(__always) @@ -391,6 +406,7 @@ struct ChatView: View { chat.created = Date.now } ), + scrollToItemId: $scrollToItemId, onSearch: { focusSearch() }, localAlias: groupInfo.localAlias ) @@ -568,7 +584,7 @@ struct ChatView: View { floatingButtonModel.updateOnListChange(scrollView.listState) } - private func scrollToItemId(_ itemId: ChatItem.ID) { + private func scrollToItem(_ itemId: ChatItem.ID) { Task { do { var index = mergedItems.boxedValue.indexInParentItems[itemId] @@ -670,7 +686,8 @@ struct ChatView: View { index: index, isLastItem: index == mergedItems.boxedValue.items.count - 1, chatItem: ci, - scrollToItemId: scrollToItemId, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, merged: mergedItem, maxWidth: maxWidth, composeState: $composeState, @@ -1234,7 +1251,8 @@ struct ChatView: View { let index: Int let isLastItem: Bool let chatItem: ChatItem - let scrollToItemId: (ChatItem.ID) -> Void + let scrollToItem: (ChatItem.ID) -> Void + @Binding var scrollToItemId: ChatItem.ID? let merged: MergedItem let maxWidth: CGFloat @Binding var composeState: ComposeState @@ -1594,7 +1612,8 @@ struct ChatView: View { chat: chat, im: im, chatItem: ci, - scrollToItemId: scrollToItemId, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, maxWidth: maxWidth, allowMenu: $allowMenu ) @@ -2668,7 +2687,8 @@ struct ChatView_Previews: PreviewProvider { chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im) + floatingButtonModel: FloatingButtonModel(im: im), + scrollToItemId: Binding.constant(nil) ) .environmentObject(chatModel) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index a9bb84c346..2336207b4a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -17,6 +17,7 @@ struct GroupChatInfoView: View { @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo + @Binding var scrollToItemId: ChatItem.ID? var onSearch: () -> Void @State var localAlias: String @FocusState private var aliasTextFieldFocused: Bool @@ -96,11 +97,11 @@ struct GroupChatInfoView: View { memberSupportButton() } if groupInfo.canModerate { - GroupReportsChatNavLink(chat: chat) + GroupReportsChatNavLink(chat: chat, scrollToItemId: $scrollToItemId) } if groupInfo.membership.memberActive && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { - UserSupportChatNavLink(chat: chat, groupInfo: groupInfo) + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) } } header: { Text("") @@ -156,9 +157,16 @@ struct GroupChatInfoView: View { let filteredMembers = s == "" ? members : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } - MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) + MemberRowView( + chat: chat, + groupInfo: groupInfo, + groupMember: GMember(groupInfo.membership), + scrollToItemId: $scrollToItemId, + user: true, + alert: $alert + ) ForEach(filteredMembers) { member in - MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, alert: $alert) + MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert) } } @@ -365,6 +373,7 @@ struct GroupChatInfoView: View { var chat: Chat var groupInfo: GroupInfo @ObservedObject var groupMember: GMember + @Binding var scrollToItemId: ChatItem.ID? @EnvironmentObject var theme: AppTheme var user: Bool = false @Binding var alert: GroupChatInfoViewAlert? @@ -427,7 +436,7 @@ struct GroupChatInfoView: View { } private func memberInfoView() -> some View { - GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember) + GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId) .navigationBarHidden(false) } @@ -540,12 +549,16 @@ struct GroupChatInfoView: View { @EnvironmentObject var theme: AppTheme var groupInfo: GroupInfo @EnvironmentObject var chatModel: ChatModel + @Binding var scrollToItemId: ChatItem.ID? @State private var navLinkActive = false var body: some View { let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) NavigationLink(isActive: $navLinkActive) { - SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) } label: { HStack { Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag") @@ -565,7 +578,7 @@ struct GroupChatInfoView: View { private func memberSupportButton() -> some View { NavigationLink { - MemberSupportView(groupInfo: groupInfo) + MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId) .navigationBarTitle("Chats with members") .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.large) @@ -588,10 +601,11 @@ struct GroupChatInfoView: View { @EnvironmentObject var theme: AppTheme @State private var navLinkActive = false @ObservedObject var chat: Chat + @Binding var scrollToItemId: ChatItem.ID? var body: some View { NavigationLink(isActive: $navLinkActive) { - SecondaryChatView(chat: chat) + SecondaryChatView(chat: chat, scrollToItemId: $scrollToItemId) } label: { HStack { Label { @@ -895,6 +909,7 @@ struct GroupChatInfoView_Previews: PreviewProvider { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), + scrollToItemId: Binding.constant(nil), onSearch: {}, localAlias: "" ) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 1e95eea2f3..a36632116b 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -16,6 +16,7 @@ struct GroupMemberInfoView: View { @State var groupInfo: GroupInfo @ObservedObject var chat: Chat @ObservedObject var groupMember: GMember + @Binding var scrollToItemId: ChatItem.ID? var navigation: Bool = false @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @@ -105,7 +106,7 @@ struct GroupMemberInfoView: View { Section { if groupInfo.membership.memberRole >= .moderator && (member.memberRole < .moderator || member.supportChat != nil) { - MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember) + MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) } if let code = connectionCode { verifyCodeButton(code) } if let connStats = connectionStats, @@ -482,12 +483,16 @@ struct GroupMemberInfoView: View { @EnvironmentObject var theme: AppTheme var groupInfo: GroupInfo var member: GMember + @Binding var scrollToItemId: ChatItem.ID? @State private var navLinkActive = false var body: some View { let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: member.wrapped) NavigationLink(isActive: $navLinkActive) { - SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) } label: { Label("Chat with member", systemImage: "flag") } @@ -846,7 +851,8 @@ struct GroupMemberInfoView_Previews: PreviewProvider { GroupMemberInfoView( groupInfo: GroupInfo.sampleData, chat: Chat.sampleData, - groupMember: GMember.sampleData + groupMember: GMember.sampleData, + scrollToItemId: Binding.constant(nil) ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 3bd2909b2d..cd053cc9c8 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -15,6 +15,7 @@ struct MemberSupportView: View { @State private var searchText: String = "" @FocusState private var searchFocussed var groupInfo: GroupInfo + @Binding var scrollToItemId: ChatItem.ID? var body: some View { viewBody() @@ -53,7 +54,8 @@ struct MemberSupportView: View { ForEach(filteredMembersWithChats) { memberWithChat in MemberSupportChatNavLink( groupInfo: groupInfo, - memberWithChat: memberWithChat + memberWithChat: memberWithChat, + scrollToItemId: $scrollToItemId ) } } @@ -66,6 +68,7 @@ struct MemberSupportView: View { @State private var memberSupportChatNavLinkActive = false var groupInfo: GroupInfo var memberWithChat: GMember + @Binding var scrollToItemId: ChatItem.ID? var body: some View { ZStack { @@ -79,7 +82,10 @@ struct MemberSupportView: View { } NavigationLink(isActive: $memberSupportChatNavLinkActive) { - SecondaryChatView(chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats())) + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) } label: { EmptyView() } @@ -253,6 +259,7 @@ func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismis #Preview { MemberSupportView( - groupInfo: GroupInfo.sampleData + groupInfo: GroupInfo.sampleData, + scrollToItemId: Binding.constant(nil) ) } diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift index 220f8f81d2..47c5df264f 100644 --- a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct SecondaryChatView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat + @Binding var scrollToItemId: ChatItem.ID? var body: some View { if let im = chatModel.secondaryIM { @@ -19,7 +20,8 @@ struct SecondaryChatView: View { chat: chat, im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im) + floatingButtonModel: FloatingButtonModel(im: im), + scrollToItemId: $scrollToItemId ) .onDisappear { chatModel.secondaryIM = nil @@ -34,6 +36,7 @@ struct SecondaryChatView: View { chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), chatItems: [], chatStats: ChatStats() - ) + ), + scrollToItemId: Binding.constant(nil) ) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9ecf91ac8a..377764ac83 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -148,6 +148,7 @@ struct ChatListView: View { @State private var userPickerShown: Bool = false @State private var sheet: SomeSheet? = nil @StateObject private var chatTagsModel = ChatTagsModel.shared + @State private var scrollToItemId: ChatItem.ID? = nil // iOS 15 is required it to show/hide toolbar while chat is hidden/visible @State private var viewOnScreen = true @@ -451,7 +452,8 @@ struct ChatListView: View { chat: chat, im: im, mergedItems: BoxedValue(MergedItems.create(im, [])), - floatingButtonModel: FloatingButtonModel(im: im) + floatingButtonModel: FloatingButtonModel(im: im), + scrollToItemId: $scrollToItemId ) } } diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index df6210496b..02dec5a618 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -367,13 +367,13 @@ struct ChatThemePreview: View { let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview")) let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)) HStack { - ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: alice, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: alice, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) .modifier(ChatItemClipped(alice, tailVisible: true)) Spacer() } HStack { Spacer() - ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) .modifier(ChatItemClipped(bob, tailVisible: true)) .frame(alignment: .trailing) } From a8da7c1c44cff6c2319f41147308423366a74993 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 15 May 2025 16:05:18 +0000 Subject: [PATCH 41/72] ios: fix auto-closing support chat for invitee when accepted; core: add accepted item to main scope (#5916) --- apps/ios/Shared/Model/SimpleXAPI.swift | 1 - src/Simplex/Chat/Library/Subscriber.hs | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 032ed46c8b..c625a5a043 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2294,7 +2294,6 @@ func processReceivedMsg(_ res: ChatEvent) async { case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo { await MainActor.run { m.secondaryPendingInviteeChatOpened = false - m.secondaryIM = nil } } } diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9761c3841d..2c4946a07a 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2121,10 +2121,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processUserAccepted = case acceptance of GAAccepted -> do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role + -- create item in both scopes let gInfo' = gInfo {membership = membership'} - scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) - groupMsgToView cInfo ci + createInternalChatItem user (CDGroupRcv gInfo' Nothing m) (CIRcvGroupEvent RGEUserAccepted) Nothing + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGEUserAccepted) Nothing toView $ CEvtUserJoinedGroup user gInfo' m let cd = CDGroupRcv gInfo' Nothing m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing From c82919cdab710b5d81c4d4edad32c98f09bc4a04 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 15 May 2025 21:57:00 +0100 Subject: [PATCH 42/72] core: 6.4.0.0 (simplexmq 6.4.0.3) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- tests/ChatClient.hs | 3 ++- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index 3e6ccab8a5..44305353f2 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: deaec3cce286e959bd594b9620c307954b510a07 + tag: f44ea0a6d8eec8abf4af177ebeb91629f7d89165 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8d17a2ce99..0c29b47a36 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."deaec3cce286e959bd594b9620c307954b510a07" = "0b8m4czjiwsi9169plslyk2rjw0f370vv7ha6qm2hpx14bxzz7xm"; + "https://github.com/simplex-chat/simplexmq.git"."f44ea0a6d8eec8abf4af177ebeb91629f7d89165" = "1biq1kq33v7hnacbhllry9n5c6dmh9dyqnz8hc5abgsv1z38qb1a"; "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 fa7f3b8be2..b220b066af 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.3.4.1 +version: 6.4.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d41957bbac..b69d703fcd 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 3, 4, 1] +minRemoteCtrlVersion = AppVersion [6, 4, 0, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 4, 1] +minRemoteHostVersion = AppVersion [6, 4, 0, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index b86f4c7d0d..ce555c676c 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -17,6 +17,7 @@ import Control.Concurrent (forkIOWithUnmask, killThread, threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, bracket_) +import Control.Logger.Simple (LogLevel (..)) import Control.Monad import Control.Monad.Except import Control.Monad.Reader @@ -535,7 +536,7 @@ smpServerCfg = allowSMPProxy = True, serverClientConcurrency = 16, information = Nothing, - startOptions = StartOptions {maintenance = False, compactLog = False, skipWarnings = False, confirmMigrations = MCYesUp} + startOptions = StartOptions {maintenance = False, compactLog = False, logLevel = LogError, skipWarnings = False, confirmMigrations = MCYesUp} } persistentServerStoreCfg :: FilePath -> AServerStoreCfg From a92c74d3e51b146816b0771f7226b683a16804a8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 16 May 2025 08:26:28 +0000 Subject: [PATCH 43/72] android, desktop: better Chat with admins indications for members; unread badges (#5917) --- .../views/chatlist/UserPicker.android.kt | 2 +- .../simplex/common/views/chat/ChatView.kt | 9 +++-- .../views/chat/group/GroupChatInfoView.kt | 40 ++++++++++++++----- .../views/chat/group/MemberSupportView.kt | 26 +++++------- .../common/views/chatlist/ChatPreviewView.kt | 35 +++++++++++----- .../common/views/chatlist/UserPicker.kt | 2 +- .../views/chatlist/UserPicker.desktop.kt | 2 +- 7 files changed, 72 insertions(+), 44 deletions(-) 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 54e3061d25..a09ca2792b 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 @@ -91,7 +91,7 @@ fun UserPickerUserBox( ProfileImageForActiveCall(size = USER_PICKER_IMAGE_SIZE, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant) if (userInfo.unreadCount > 0 && !userInfo.user.activeUser) { - unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs, false) + userUnreadBadge(userInfo.unreadCount, userInfo.user.showNtfs, false) } } val user = userInfo.user 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 ca9f2983d4..fc26a9c8a3 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 @@ -898,7 +898,6 @@ fun ChatLayout( if (oneHandUI.value && chatBottomBar.value) { if ( chatInfo is ChatInfo.Group - && chatInfo.groupInfo.canModerate && chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0) ) { @@ -960,8 +959,6 @@ fun ChatLayout( } if ( chatInfo is ChatInfo.Group - && chatInfo.groupInfo.canModerate - && chatsCtx.contentTag == null && (reportsCount > 0 || supportUnreadCount > 0) && (!oneHandUI.value || !chatBottomBar.value) ) { @@ -1212,7 +1209,11 @@ private fun SupportChatsCountToolbar( .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { - if (reportsCount > 0) { + if ( + chatInfo is ChatInfo.Group + && chatInfo.groupInfo.canModerate + && reportsCount > 0 + ) { Row( Modifier .fillMaxWidth() 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 ee0d0e33cd..cb1794b568 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 @@ -331,13 +331,14 @@ fun AddGroupMembersButton( @Composable fun UserSupportChatButton( + chat: Chat, groupInfo: GroupInfo, scrollToItemId: MutableState ) { val scope = rememberCoroutineScope() - SettingsActionItem( - painterResource(MR.images.ic_flag), + SettingsActionItemWithContent( + painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.button_support_chat), click = { val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) @@ -351,8 +352,15 @@ fun UserSupportChatButton( ) } }, - iconColor = MaterialTheme.colors.secondary, - ) + iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary), + ) { + if (chat.supportUnreadCount > 0) { + UnreadBadge( + text = unreadCountStr(chat.supportUnreadCount), + backgroundColor = MaterialTheme.colors.primary + ) + } + } } @Composable @@ -488,7 +496,7 @@ fun ModalData.GroupChatInfoLayout( (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) ) { anyTopSectionRowShow = true - UserSupportChatButton(groupInfo, scrollToItemId) + UserSupportChatButton(chat, groupInfo, scrollToItemId) } } if (anyTopSectionRowShow) { @@ -743,12 +751,19 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { @Composable private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { - SettingsActionItem( + SettingsActionItemWithContent( painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.member_support), click = onClick, iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) - ) + ) { + if (chat.supportUnreadCount > 0) { + UnreadBadge( + text = unreadCountStr(chat.supportUnreadCount), + backgroundColor = MaterialTheme.colors.primary + ) + } + } } @Composable @@ -762,12 +777,19 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) @Composable private fun GroupReportsButton(chat: Chat, onClick: () -> Unit) { - SettingsActionItem( + SettingsActionItemWithContent( painterResource(if (chat.chatStats.reportsCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.group_reports_member_reports), click = onClick, iconColor = (if (chat.chatStats.reportsCount > 0) Color.Red else MaterialTheme.colors.secondary) - ) + ) { + if (chat.chatStats.reportsCount > 0) { + UnreadBadge( + text = unreadCountStr(chat.chatStats.reportsCount), + backgroundColor = Color.Red + ) + } + } } @Composable 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 46b0c05a31..0ef63a2a11 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 @@ -25,8 +25,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction -import chat.simplex.common.views.chatlist.setGroupMembers -import chat.simplex.common.views.chatlist.unreadCountStr +import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch @@ -179,12 +178,12 @@ fun SupportChatRow(member: GroupMember) { Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { if (supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0) { - val indicatorTint = when { + val unreadBadgeColor = when { supportChat.mentions > 0 || supportChat.memberAttention > 0 -> MaterialTheme.colors.primaryVariant else -> MaterialTheme.colors.secondary } if (supportChat.mentions == 1 && supportChat.unread == 1) { - Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(indicatorTint, shape = CircleShape), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(unreadBadgeColor, shape = CircleShape), contentAlignment = Alignment.Center) { Icon( painterResource(MR.images.ic_alternate_email), contentDescription = generalGetString(MR.strings.notifications), @@ -197,22 +196,15 @@ fun SupportChatRow(member: GroupMember) { Icon( painterResource(MR.images.ic_alternate_email), contentDescription = generalGetString(MR.strings.notifications), - tint = indicatorTint, - modifier = Modifier.size(12.sp.toDp()).offset(y = 3.sp.toDp()) + tint = unreadBadgeColor, + modifier = Modifier.size(12.sp.toDp()).offset(y = 2.sp.toDp()) ) } - Text( - unreadCountStr(supportChat.unread), - color = Color.White, - fontSize = 10.sp, - style = TextStyle(textAlign = TextAlign.Center), - modifier = Modifier - .offset(y = 3.sp.toDp()) - .background(indicatorTint, shape = CircleShape) - .badgeLayout() - .padding(horizontal = 2.sp.toDp()) - .padding(vertical = 1.sp.toDp()) + UnreadBadge( + text = unreadCountStr(supportChat.unread), + backgroundColor = unreadBadgeColor, + yOffset = 2.dp ) } } 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 2d375389dc..65a9d175dd 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 @@ -471,17 +471,10 @@ fun ChatPreviewView( ) } } else { - Text( - if (n > 0) unreadCountStr(n) else "", - color = Color.White, - fontSize = 10.sp, - style = TextStyle(textAlign = TextAlign.Center), - modifier = Modifier - .offset(y = 3.sp.toDp()) - .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) - .badgeLayout() - .padding(horizontal = 2.sp.toDp()) - .padding(vertical = 1.sp.toDp()) + UnreadBadge( + text = if (n > 0) unreadCountStr(n) else "", + backgroundColor = if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, + yOffset = 3.dp ) } } @@ -573,6 +566,26 @@ private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, g stringResource(MR.strings.group_preview_you_are_invited) } +@Composable +fun UnreadBadge( + text: String, + backgroundColor: Color, + yOffset: Dp? = null +) { + Text( + text, + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = yOffset ?: 0.dp) + .background(backgroundColor, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) +} + @Composable fun unreadCountStr(n: Int): String { return if (n < 1000) "$n" else "${n / 1000}" + stringResource(MR.strings.thousand_abbreviation) 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 185ec3925f..13351a2111 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 @@ -634,7 +634,7 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) { } @Composable -fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean, hasPadding: Boolean) { +fun BoxScope.userUnreadBadge(unreadCount: Int, userMuted: Boolean, hasPadding: Boolean) { Text( if (unreadCount > 0) unreadCountStr(unreadCount) else "", color = Color.White, 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 3855835ab6..52e845b422 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 @@ -63,7 +63,7 @@ actual fun UserPickerUsersSection( ProfileImage(size = 55.dp, image = user.profile.image, color = iconColor) if (u.unreadCount > 0 && !user.activeUser) { - unreadBadge(u.unreadCount, user.showNtfs, true) + userUnreadBadge(u.unreadCount, user.showNtfs, true) } } From 5e7f77d1d16010e6635c2a2e4aec4e8285efadda Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 16 May 2025 10:54:13 +0000 Subject: [PATCH 44/72] android: go back to chat list after closing support chat when invitee is pending (#5918) --- .../chat/simplex/common/model/ChatModel.kt | 6 ++++++ .../views/chat/group/MemberSupportChatView.kt | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) 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 f50b4a99ca..2a47e22be8 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 @@ -323,6 +323,12 @@ object ChatModel { is SecondaryContextFilter.MsgContentTagContext -> null } + val isUserSupportChat: Boolean = + when (groupScopeInfo) { + null -> false + is GroupChatScopeInfo.MemberSupport -> groupScopeInfo.groupMember_ == null + } + suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0) 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 3196cae15c..99e2e3198e 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 @@ -22,6 +22,7 @@ import dev.icerock.moko.resources.compose.stringResource @Composable private fun MemberSupportChatView( + chatInfo: ChatInfo, memberSupportChatsCtx: ChatModel.ChatsContext, staleChatId: State, scrollToItemId: MutableState @@ -29,6 +30,21 @@ private fun MemberSupportChatView( KeyChangeEffect(chatModel.chatId.value) { ModalManager.end.closeModals() } + if (appPlatform.isAndroid) { + DisposableEffect(Unit) { + onDispose { + val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatInfo.id } + if ( + memberSupportChatsCtx.isUserSupportChat + && chat?.chatInfo?.groupInfo_?.membership?.memberPending == true + ) { + withBGApi { + chatModel.chatId.value = null + } + } + } + } + } ChatView(memberSupportChatsCtx, staleChatId, scrollToItemId, onComposed = {}) } @@ -118,7 +134,7 @@ suspend fun showMemberSupportChatView(staleChatId: State, scrollToItemI ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> ModalView({}, showAppBar = false) { if (chatInfo is ChatInfo.Group && chatInfo.groupChatScope != null) { - MemberSupportChatView(memberSupportChatsCtx, staleChatId, scrollToItemId) + MemberSupportChatView(chatInfo, memberSupportChatsCtx, staleChatId, scrollToItemId) } else { LaunchedEffect(Unit) { close() From 041807c8700801a8e39c85799bb729c7a088fac4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 16 May 2025 15:03:15 +0000 Subject: [PATCH 45/72] core: decrease membersRequireAttention counter when member is deleted or leaves (#5919) --- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +++-- .../Views/Chat/Group/GroupChatInfoView.swift | 7 ++--- .../Chat/Group/GroupMemberInfoView.swift | 5 ++-- .../Views/ChatList/ChatListNavLink.swift | 4 +-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +++++------ apps/ios/SimpleXChat/ChatTypes.swift | 6 ++++- .../chat/simplex/common/model/ChatModel.kt | 5 +++- .../chat/simplex/common/model/SimpleXAPI.kt | 6 +++-- .../views/chat/group/GroupChatInfoView.kt | 12 +++++---- .../views/chat/group/GroupMemberInfoView.kt | 10 ++++--- .../views/chatlist/ChatListNavLinkView.kt | 4 +-- src/Simplex/Chat/Library/Commands.hs | 24 ++++++++++++----- src/Simplex/Chat/Library/Internal.hs | 15 +++++++---- src/Simplex/Chat/Library/Subscriber.hs | 16 ++++++----- src/Simplex/Chat/Store/Groups.hs | 27 +++++++++++-------- .../SQLite/Migrations/chat_query_plans.txt | 16 +++++------ 16 files changed, 111 insertions(+), 68 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index c625a5a043..844f888e29 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1646,9 +1646,9 @@ func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: Gro throw r.unexpected } -func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> [GroupMember] { +func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (GroupInfo, [GroupMember]) { let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) - if case let .userDeletedMembers(_, _, members, withMessages) = r { return members } + if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) } throw r.unexpected } @@ -2267,6 +2267,7 @@ func processReceivedMsg(_ res: ChatEvent) async { case let .deletedMember(user, groupInfo, byMember, deletedMember, withMessages): if active(user) { await MainActor.run { + m.updateGroup(groupInfo) _ = m.upsertGroupMember(groupInfo, deletedMember) if withMessages { m.removeMemberItems(deletedMember, byMember: byMember, groupInfo) @@ -2276,6 +2277,7 @@ func processReceivedMsg(_ res: ChatEvent) async { case let .leftMember(user, groupInfo, member): if active(user) { await MainActor.run { + m.updateGroup(groupInfo) _ = m.upsertGroupMember(groupInfo, member) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 2336207b4a..4218e94224 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -175,7 +175,7 @@ struct GroupChatInfoView: View { if groupInfo.canDelete { deleteGroupButton() } - if groupInfo.membership.memberCurrent { + if groupInfo.membership.memberCurrentOrPending { leaveGroupButton() } } @@ -797,10 +797,11 @@ struct GroupChatInfoView: View { func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { Task { do { - let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) await MainActor.run { + ChatModel.shared.updateGroup(updatedGroupInfo) updatedMembers.forEach { updatedMember in - _ = ChatModel.shared.upsertGroupMember(groupInfo, updatedMember) + _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) } dismiss?() } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index a36632116b..fa7fc7cae4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -640,10 +640,11 @@ struct GroupMemberInfoView: View { primaryButton: .destructive(Text("Remove")) { Task { do { - let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) await MainActor.run { + chatModel.updateGroup(updatedGroupInfo) updatedMembers.forEach { updatedMember in - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + _ = chatModel.upsertGroupMember(updatedGroupInfo, updatedMember) } dismiss() } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 6d590fcc52..1e747b8019 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -189,7 +189,7 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing) { tagChatButton(chat) - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { leaveGroupChatButton(groupInfo) } if groupInfo.canDelete { @@ -214,7 +214,7 @@ struct ChatListNavLink: View { let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator let showClearButton = !chat.chatItems.isEmpty let showDeleteGroup = groupInfo.canDelete - let showLeaveGroup = groupInfo.membership.memberCurrent + let showLeaveGroup = groupInfo.membership.memberCurrentOrPending let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) if showClearButton && totalNumberOfButtons <= 3 { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 59f018e9fe..77b4ef39a8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -179,8 +179,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.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.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 */; }; @@ -543,8 +543,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.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.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 = ""; }; @@ -702,8 +702,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -788,8 +788,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 81fc500072..d068b50412 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2043,7 +2043,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { } public var canDelete: Bool { - return membership.memberRole == .owner || !membership.memberCurrent + return membership.memberRole == .owner || !membership.memberCurrentOrPending } public var canAddMembers: Bool { @@ -2275,6 +2275,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable { } } + public var memberCurrentOrPending: Bool { + memberCurrent || memberPending + } + public func canBeRemoved(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole return memberStatus != .memRemoved && memberStatus != .memLeft 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 2a47e22be8..9329fe5dda 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 @@ -1883,7 +1883,7 @@ data class GroupInfo ( get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent val canDelete: Boolean - get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrent + get() = membership.memberRole == GroupMemberRole.Owner || !membership.memberCurrentOrPending val canAddMembers: Boolean get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive @@ -2090,6 +2090,9 @@ data class GroupMember ( else -> false } + val memberCurrentOrPending: Boolean get() = + memberCurrent || memberPending + fun canBeRemoved(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index ebcf502105..12c93888ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1911,9 +1911,9 @@ object ChatController { return null } - suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): List? { + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): Pair>? { val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) - if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.members + if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members if (!(networkErrorAlert(r))) { apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r) } @@ -2603,6 +2603,7 @@ object ChatController { is CR.DeletedMember -> if (active(r.user)) { withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) if (r.withMessages) { chatModel.chatsContext.removeMemberItems(rhId, r.deletedMember, byMember = r.byMember, r.groupInfo) @@ -2618,6 +2619,7 @@ object ChatController { is CR.LeftMember -> if (active(r.user)) { withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } withContext(Dispatchers.Main) { 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 cb1794b568..db6eff562e 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 @@ -596,7 +596,7 @@ fun ModalData.GroupChatInfoLayout( val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat DeleteGroupButton(titleId, deleteGroup) } - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat LeaveGroupButton(titleId, leaveGroup) } @@ -1055,16 +1055,18 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { withBGApi { - val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) - if (updatedMembers != null) { + val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) + if (r != null) { + val (updatedGroupInfo, updatedMembers) = r withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) updatedMembers.forEach { updatedMember -> - chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, updatedMember) + chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) } } withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, updatedMember) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) } } onSuccess() 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 5db1ba93f7..0ce0f8fa3c 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 @@ -246,16 +246,18 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withBGApi { - val removedMembers = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) - if (removedMembers != null) { + val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + if (r != null) { + val (updatedGroupInfo, removedMembers) = r withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) removedMembers.forEach { removedMember -> - chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, removedMember) + chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) } } withContext(Dispatchers.Main) { removedMembers.forEach { removedMember -> - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, removedMember) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, removedMember) } } } 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 b70a4e00d1..52b4059eef 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 @@ -304,7 +304,7 @@ fun GroupMenuItems( } } GroupMemberStatus.MemAccepted -> { - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -326,7 +326,7 @@ fun GroupMenuItems( } } ClearChatAction(chat, showMenu) - if (groupInfo.membership.memberCurrent) { + if (groupInfo.membership.memberCurrentOrPending) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 656191d0b5..f1ef1f369f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2077,8 +2077,8 @@ processChatCommand' vr = \case void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg when (maxVersion (memberChatVRange m) < groupKnockingVersion) $ forM_ (memberConn m) $ \mConn -> do - let msg = XMsgNew $ MCSimple $ extMsgContent (MCText acceptedToGroupMessage) Nothing - void $ sendDirectMemberMessage mConn msg groupId + let msg2 = XMsgNew $ MCSimple $ extMsgContent (MCText acceptedToGroupMessage) Nothing + void $ sendDirectMemberMessage mConn msg2 groupId (m', gInfo') <- withFastStore' $ \db -> do m' <- updateGroupMemberAccepted db user m newMemberStatus role gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' @@ -2227,10 +2227,13 @@ processChatCommand' vr = \case let acis = acis2 <> acis3 <> acis4 errs = errs1 <> errs2 <> errs3 <> errs4 deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 - unless (null acis) $ toView $ CEvtNewChatItems user acis + -- Read group info with updated membersRequireAttention + gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId + let acis' = map (updateCIGroupInfo gInfo') acis + unless (null acis') $ toView $ CEvtNewChatItems user acis' unless (null errs) $ toView $ CEvtChatErrors errs - when withMessages $ deleteMessages user gInfo deleted - pure $ CRUserDeletedMembers user gInfo deleted withMessages -- same order is not guaranteed + when withMessages $ deleteMessages user gInfo' deleted + pure $ CRUserDeletedMembers user gInfo' deleted withMessages -- same order is not guaranteed where selectMembers :: [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) selectMembers = foldl' addMember (0, [], [], [], [], GRObserver, False) @@ -2280,8 +2283,17 @@ processChatCommand' vr = \case ts = ciContentTexts content in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing delMember db m = do - deleteOrUpdateMemberRecordIO db user m + -- We're in a function used in batch member deletion, and since we're passing same gInfo for each member, + -- voided result (updated group info) may have incorrect state of membersRequireAttention. + -- To avoid complicating code by chaining group info updates, + -- instead we re-read it once after deleting all members before response. + void $ deleteOrUpdateMemberRecordIO db user gInfo m pure m {memberStatus = GSMemRemoved} + updateCIGroupInfo :: GroupInfo -> AChatItem -> AChatItem + updateCIGroupInfo gInfo' = \case + AChatItem SCTGroup SMDSnd (GroupChat _gInfo chatScopeInfo) ci -> + AChatItem SCTGroup SMDSnd (GroupChat gInfo' chatScopeInfo) ci + aci -> aci deleteMessages user gInfo@GroupInfo {membership} ms | groupFeatureMemberAllowed SGFFullDelete membership gInfo = deleteGroupMembersCIs user gInfo ms membership | otherwise = markGroupMembersCIsDeleted user gInfo ms membership diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 60be94ccc9..6200fb2435 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1564,15 +1564,20 @@ deleteMemberConnection' GroupMember {activeConn} waitDelivery = do deleteAgentConnectionAsync' (aConnId conn) waitDelivery withStore' $ \db -> updateConnectionStatus db conn ConnDeleted -deleteOrUpdateMemberRecord :: User -> GroupMember -> CM () -deleteOrUpdateMemberRecord user member = - withStore' $ \db -> deleteOrUpdateMemberRecordIO db user member +deleteOrUpdateMemberRecord :: User -> GroupInfo -> GroupMember -> CM GroupInfo +deleteOrUpdateMemberRecord user gInfo member = + withStore' $ \db -> deleteOrUpdateMemberRecordIO db user gInfo member -deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupMember -> IO () -deleteOrUpdateMemberRecordIO db user@User {userId} member = +deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo +deleteOrUpdateMemberRecordIO db user@User {userId} gInfo member = do + gInfo' <- + if gmRequiresAttention member + then decreaseGroupMembersRequireAttention db user gInfo + else pure gInfo checkGroupMemberHasItems db user member >>= \case Just _ -> updateGroupMemberStatus db userId member GSMemRemoved Nothing -> deleteGroupMember db user member + pure gInfo' sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages user ct events = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2c4946a07a..12e6263ba0 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2697,10 +2697,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave deleteMemberConnection member -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user member + gInfo' <- deleteOrUpdateMemberRecord user gInfo member when withMessages $ deleteMessages member SMDRcv deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CEvtDeletedMember user gInfo m member {memberStatus = GSMemRemoved} withMessages + toView $ CEvtDeletedMember user gInfo' m member {memberStatus = GSMemRemoved} withMessages where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = @@ -2719,11 +2719,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item - withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + gInfo' <- withStore' $ \db -> do + updateGroupMemberStatus db userId m GSMemLeft + if gmRequiresAttention m + then decreaseGroupMembersRequireAttention db user gInfo + else pure gInfo + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView cInfo ci - toView $ CEvtLeftMember user gInfo' m' {memberStatus = GSMemLeft} + toView $ CEvtLeftMember user gInfo'' m' {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 35e7fe96a9..68fdb5c0be 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -81,6 +81,7 @@ module Simplex.Chat.Store.Groups updateGroupMemberStatusById, updateGroupMemberAccepted, updateGroupMembersRequireAttention, + decreaseGroupMembersRequireAttention, increaseGroupMembersRequireAttention, createNewGroupMember, checkGroupMemberHasItems, @@ -1231,24 +1232,28 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo -updateGroupMembersRequireAttention db user@User {userId} g@GroupInfo {groupId, membersRequireAttention} member member' +updateGroupMembersRequireAttention db user g member member' | nowRequires && not didRequire = increaseGroupMembersRequireAttention db user g - | not nowRequires && didRequire = do - DB.execute - db - [sql| - UPDATE groups - SET members_require_attention = members_require_attention - 1 - WHERE user_id = ? AND group_id = ? - |] - (userId, groupId) - pure g {membersRequireAttention = membersRequireAttention - 1} + | not nowRequires && didRequire = + decreaseGroupMembersRequireAttention db user g | otherwise = pure g where didRequire = gmRequiresAttention member nowRequires = gmRequiresAttention member' +decreaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo +decreaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention - 1} + increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do DB.execute 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 76068e61e5..44b915a8fe 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1335,14 +1335,6 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE groups - SET members_require_attention = members_require_attention - 1 - WHERE user_id = ? AND group_id = ? - -Plan: -SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE user_contact_links SET auto_accept = ?, business_address = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? @@ -4451,6 +4443,14 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) From 507feb92a57023de09e9b519e11c6ed3ecf618b5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 16 May 2025 19:26:46 +0100 Subject: [PATCH 46/72] core: 6.4.0.1 --- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b220b066af..7b09e9c926 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.4.0.0 +version: 6.4.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index b69d703fcd..b71b103919 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 4, 0, 0] +minRemoteCtrlVersion = AppVersion [6, 4, 0, 1] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 4, 0, 0] +minRemoteHostVersion = AppVersion [6, 4, 0, 1] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From cc7bc0536df53e6aacd9452642585758049a9eeb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 16 May 2025 21:11:21 +0100 Subject: [PATCH 47/72] 6.4-beta.0: ios 278, android 290, desktop 102 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 77b4ef39a8..0a0d5b4458 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -179,8 +179,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.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.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 */; }; @@ -543,8 +543,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.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.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 = ""; }; @@ -702,8 +702,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -788,8 +788,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.0-Adp18CY8iMhDelg0G0VSjh.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */, ); path = Libraries; sourceTree = ""; @@ -1991,7 +1991,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2016,7 +2016,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2041,7 +2041,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2066,7 +2066,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2083,11 +2083,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2103,11 +2103,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2128,7 +2128,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2143,7 +2143,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2165,7 +2165,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2180,7 +2180,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2202,7 +2202,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2228,7 +2228,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2253,7 +2253,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2279,7 +2279,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2304,7 +2304,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2319,7 +2319,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2338,7 +2338,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 278; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2353,7 +2353,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.4; 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 18add58bcf..4623752b32 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.3.4 -android.version_code=288 +android.version_name=6.4-beta.0 +android.version_code=290 -desktop.version_name=6.3.4 -desktop.version_code=101 +desktop.version_name=6.4-beta.0 +desktop.version_code=102 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 98f35a38e84543acb9527035f9abe0c4298b0516 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 May 2025 09:08:28 +0000 Subject: [PATCH 48/72] core: fix member introduction when inviting admin accepts member; fix order of items for accepted member (#5924) --- src/Simplex/Chat/Library/Commands.hs | 3 +++ src/Simplex/Chat/Library/Subscriber.hs | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f1ef1f369f..950eef7704 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2079,6 +2079,9 @@ processChatCommand' vr = \case forM_ (memberConn m) $ \mConn -> do let msg2 = XMsgNew $ MCSimple $ extMsgContent (MCText acceptedToGroupMessage) Nothing void $ sendDirectMemberMessage mConn msg2 groupId + when (memberCategory m == GCInviteeMember) $ do + introduceToRemaining vr 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 gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 12e6263ba0..0ecb151120 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2123,14 +2123,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role -- create item in both scopes let gInfo' = gInfo {membership = membership'} - createInternalChatItem user (CDGroupRcv gInfo' Nothing m) (CIRcvGroupEvent RGEUserAccepted) Nothing - let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} - createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGEUserAccepted) Nothing - toView $ CEvtUserJoinedGroup user gInfo' m - let cd = CDGroupRcv gInfo' Nothing m + cd = CDGroupRcv gInfo' Nothing m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CIRcvGroupFeature gInfo' maybeCreateGroupDescrLocal gInfo' m + createInternalChatItem user cd (CIRcvGroupEvent RGEUserAccepted) Nothing + let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGEUserAccepted) Nothing + toView $ CEvtUserJoinedGroup user gInfo' m GAPendingReview -> do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemPendingReview role let gInfo' = gInfo {membership = membership'} From a2577c3df3a411f539db29daafbcfa7a27fe3983 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 May 2025 11:14:43 +0100 Subject: [PATCH 49/72] core: support observers send messages to admins, CLI commands (#5921) * core: allow observers send messages to admins * improve parser, CLI command to approve members --- src/Simplex/Chat/Controller.hs | 3 +- src/Simplex/Chat/Library/Commands.hs | 66 +++++++++++++++++--------- src/Simplex/Chat/Library/Subscriber.hs | 29 ++++++----- src/Simplex/Chat/Messages.hs | 10 ++++ src/Simplex/Chat/Protocol.hs | 19 +++++--- src/Simplex/Chat/View.hs | 2 +- tests/ChatTests/Groups.hs | 24 +++++++++- tests/ProtocolTests.hs | 4 +- 8 files changed, 112 insertions(+), 45 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7653322c8d..36d6b0aa4a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -469,7 +469,7 @@ data ChatCommand | ForwardMessage {toChatName :: ChatName, fromContactName :: ContactName, forwardedMsg :: Text} | ForwardGroupMessage {toChatName :: ChatName, fromGroupName :: GroupName, fromMemberName_ :: Maybe ContactName, forwardedMsg :: Text} | ForwardLocalMessage {toChatName :: ChatName, forwardedMsg :: Text} - | SendMessage ChatName Text + | SendMessage SendName Text | SendMemberContactMessage GroupName ContactName Text | SendLiveMessage ChatName Text | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: Text, message :: Text} @@ -483,6 +483,7 @@ data ChatCommand | NewGroup IncognitoEnabled GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} + | AcceptMember GroupName ContactName GroupMemberRole | MemberRole GroupName ContactName GroupMemberRole | BlockForAll GroupName ContactName Bool | RemoveMembers {groupName :: GroupName, members :: Set ContactName, withMessages :: Bool} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 950eef7704..7e0ff3b4c7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -27,6 +27,7 @@ import Control.Monad.Reader import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.Attoparsec.Combinator as A import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -1848,10 +1849,10 @@ processChatCommand' vr = \case forwardedItemId <- withFastStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg toChatRef <- getChatRef user toChatName processChatCommand $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing - SendMessage (ChatName cType name) msg -> withUser $ \user -> do + SendMessage sendName msg -> withUser $ \user -> do let mc = MCText msg - case cType of - CTDirect -> + case sendName of + SNDirect name -> withFastStore' (\db -> runExceptT $ getContactIdByName db user name) >>= \case Right ctId -> do let sendRef = SRDirect ctId @@ -1866,18 +1867,18 @@ processChatCommand' vr = \case throwChatError $ CEContactNotFound name (Just suspectedMember) _ -> throwChatError $ CEContactNotFound name Nothing - CTGroup -> do - (gId, mentions) <- withFastStore $ \db -> do + SNGroup name scope_ -> do + (gId, cScope_, mentions) <- withFastStore $ \db -> do gId <- getGroupIdByName db user name - (gId,) <$> liftIO (getMessageMentions db user gId msg) - let sendRef = SRGroup gId Nothing + cScope_ <- + forM scope_ $ \(GSNMemberSupport mName_) -> + GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ + (gId,cScope_,) <$> liftIO (getMessageMentions db user gId msg) + let sendRef = SRGroup gId cScope_ processChatCommand $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] - CTLocal - | name == "" -> do - folderId <- withFastStore (`getUserNoteFolderId` user) - processChatCommand $ APICreateChatItems folderId [composedMessage Nothing mc] - | otherwise -> throwCmdError "not supported" - _ -> throwCmdError "not supported" + SNLocal -> do + folderId <- withFastStore (`getUserNoteFolderId` user) + processChatCommand $ 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 @@ -2331,6 +2332,7 @@ processChatCommand' vr = \case JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ 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 RemoveMembers gName gMemberNames withMessages -> withUser $ \user -> do @@ -2421,7 +2423,8 @@ processChatCommand' vr = \case when (contactGrpInvSent ct) $ throwCmdError "x.grp.direct.inv already sent" case memberConn m of Just mConn -> do - let msg = XGrpDirectInv cReq msgContent_ + -- TODO [knocking] send in correct scope - modiy API + let msg = XGrpDirectInv cReq msgContent_ Nothing (sndMsg, _, _) <- sendDirectMemberMessage mConn msg groupId withFastStore' $ \db -> setContactGrpInvSent db ct True let ct' = ct {contactGrpInvSent = True} @@ -3372,7 +3375,7 @@ processChatCommand' vr = \case Nothing -> Just GRAuthor Just (GCSMemberSupport Nothing) | memberPending membership -> Nothing - | otherwise -> Just GRAuthor + | otherwise -> Just GRObserver Just (GCSMemberSupport (Just _gmId)) -> Just GRModerator assertGroupContentAllowed :: CM () assertGroupContentAllowed = @@ -4190,6 +4193,7 @@ chatCommandP = "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)), + "/accept member " *> char_ '#' *> (AcceptMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> memberRole), "/block for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/unblock for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), @@ -4236,8 +4240,7 @@ chatCommandP = ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, ForwardLocalMessage <$> chatNameP <* " <- * " <*> msgTextP, - SendMessage <$> chatNameP <* A.space <*> msgTextP, - "/* " *> (SendMessage (ChatName CTLocal "") <$> msgTextP), + SendMessage <$> sendNameP <* A.space <*> msgTextP, "@#" *> (SendMemberContactMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> msgTextP), "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), @@ -4440,14 +4443,27 @@ chatCommandP = chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP chatRefP = do chatTypeP >>= \case - CTGroup -> ChatRef CTGroup <$> A.decimal <*> (Just <$> gcScopeP <|> pure Nothing) + CTGroup -> ChatRef CTGroup <$> A.decimal <*> optional gcScopeP cType -> (\chatId -> ChatRef cType chatId Nothing) <$> A.decimal sendRefP = (A.char '@' $> SRDirect <*> A.decimal) - <|> (A.char '#' $> SRGroup <*> A.decimal <*> (Just <$> gcScopeP <|> pure Nothing)) - gcScopeP = - ("(_support:" *> (GCSMemberSupport . Just <$> A.decimal) <* ")") - <|> ("(_support)" $> (GCSMemberSupport Nothing)) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP) + gcScopeP = "(_support" *> (GCSMemberSupport <$> optional (A.char ':' *> A.decimal)) <* A.char ')' + sendNameP = + (A.char '@' $> SNDirect <*> displayNameP) + <|> (A.char '#' $> SNGroup <*> displayNameP <*> gScopeNameP) + <|> ("/*" $> SNLocal) + gScopeNameP = + (supportPfx *> (Just . GSNMemberSupport <$> optional supportMember) <* A.char ')') + -- this branch fails on "(support" followed by incorrect syntax, + -- to avoid sending message to the whole group as `optional gScopeNameP` would do + <|> (optional supportPfx >>= mapM (\_ -> fail "bad chat scope")) + where + supportPfx = A.takeWhile isSpace *> "(support" + supportMember = safeDecodeUtf8 <$> (A.char ':' *> A.takeWhile isSpace *> (A.take . lengthTillLastParen =<< A.lookAhead displayNameP_)) + lengthTillLastParen s = case B.unsnoc s of + Just (_, ')') -> B.length s - 1 + _ -> B.length s msgCountP = A.space *> A.decimal <|> pure 10 ciTTLDecimal = ("default" $> Nothing) <|> (Just <$> A.decimal) ciTTL = @@ -4513,7 +4529,11 @@ chatCommandP = char_ = optional . A.char displayNameP :: Parser Text -displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',')) +displayNameP = safeDecodeUtf8 <$> displayNameP_ +{-# INLINE displayNameP #-} + +displayNameP_ :: Parser ByteString +displayNameP_ = quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',') where takeNameTill p = A.peekChar' >>= \c -> diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 0ecb151120..e554ed4424 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -871,9 +871,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = logInfo $ "group msg=" <> tshow tag <> " " <> eInfo (m'', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta msgBody chatMsg case event of - XMsgNew mc -> memberCanSend m'' $ newGroupContentMessage gInfo' m'' mc msg brokerTs False - XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m'' $ groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live + XMsgNew mc -> memberCanSend m'' scope $ newGroupContentMessage gInfo' m'' mc msg brokerTs False + where ExtMsgContent {scope} = mcExtMsgContent mc + -- file description is always allowed, to allow sending files to support scope + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo' m'' sharedMsgId memberId msg brokerTs XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId reaction add msg brokerTs -- TODO discontinue XFile @@ -895,7 +897,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XGrpDel -> xGrpDel gInfo' m'' msg brokerTs XGrpInfo p' -> xGrpInfo gInfo' m'' p' msg brokerTs XGrpPrefs ps' -> xGrpPrefs gInfo' m'' ps' - XGrpDirectInv connReq mContent_ -> memberCanSend m'' $ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs + -- TODO [knocking] why don't we forward these messages? + XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs XGrpMsgForward memberId msg' msgTs -> xGrpMsgForward gInfo' m'' memberId msg' msgTs XInfoProbe probe -> xInfoProbe (COMGroupMember m'') probe XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m'') probeHash @@ -1252,10 +1255,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason _ -> toView $ CEvtReceivedContactRequest user cReq - memberCanSend :: GroupMember -> CM () -> CM () - memberCanSend m@GroupMember {memberRole} a - | memberRole > GRObserver || memberPending m = a - | otherwise = messageError "member is not allowed to send messages" + memberCanSend :: GroupMember -> Maybe MsgScope -> CM () -> CM () + memberCanSend m@GroupMember {memberRole} msgScope a = case msgScope of + Just MSMember {} -> a + Nothing + | memberRole > GRObserver || memberPending m -> a + | otherwise -> messageError "member is not allowed to send messages" processConnMERR :: ConnectionEntity -> Connection -> AgentErrorType -> CM () processConnMERR connEntity conn err = do @@ -2839,9 +2844,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let body = LB.toStrict $ J.encode msg rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg case event of - XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs True - XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live + XMsgNew mc -> memberCanSend author scope $ newGroupContentMessage gInfo author mc rcvMsg msgTs True + where ExtMsgContent {scope} = mcExtMsgContent mc + -- file description is always allowed, to allow sending files to support scope + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo author sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend author msgScope $ groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs XMsgReact sharedMsgId (Just memId) reaction add -> groupMsgReaction gInfo author sharedMsgId memId reaction add rcvMsg msgTs XFileCancel sharedMsgId -> xFileCancelGroup gInfo author sharedMsgId diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 00dd999139..9a8dc786cd 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -83,6 +83,16 @@ instance TextEncoding GroupChatScopeTag where data ChatName = ChatName {chatType :: ChatType, chatName :: Text} deriving (Show) +data SendName + = SNDirect ContactName + | SNGroup GroupName (Maybe GroupScopeName) + | SNLocal + deriving (Show) + +data GroupScopeName + = GSNMemberSupport (Maybe ContactName) + deriving (Show) + chatTypeStr :: ChatType -> Text chatTypeStr = \case CTDirect -> "@" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 57383e7b11..151b1b0d27 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -361,7 +361,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpDel :: ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json - XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> ChatMsgEvent 'Json + XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMsgForward :: MemberId -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json @@ -1011,7 +1011,7 @@ toCMEventTag msg = case msg of XGrpDel -> XGrpDel_ XGrpInfo _ -> XGrpInfo_ XGrpPrefs _ -> XGrpPrefs_ - XGrpDirectInv _ _ -> XGrpDirectInv_ + XGrpDirectInv {} -> XGrpDirectInv_ XGrpMsgForward {} -> XGrpMsgForward_ XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ @@ -1083,7 +1083,14 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do msg = \case XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" - XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> (fromMaybe M.empty <$> opt "mentions") <*> opt "ttl" <*> opt "live" <*> opt "scope" + XMsgUpdate_ -> do + msgId' <- p "msgId" + content <- p "content" + mentions <- fromMaybe M.empty <$> opt "mentions" + ttl <- opt "ttl" + live <- opt "live" + scope <- opt "scope" + pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> p "reaction" <*> p "add" @@ -1114,7 +1121,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpDel_ -> pure XGrpDel XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" - XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" + XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" XGrpMsgForward_ -> XGrpMsgForward <$> p "memberId" <*> p "msg" <*> p "msgTs" XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" @@ -1147,7 +1154,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgUpdate msgId' content mentions ttl live scope -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] + XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] @@ -1178,7 +1185,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpDel -> JM.empty XGrpInfo p -> o ["groupProfile" .= p] XGrpPrefs p -> o ["groupPreferences" .= p] - XGrpDirectInv connReq content -> o $ ("content" .=? content) ["connReq" .= connReq] + XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] XGrpMsgForward memberId msg msgTs -> o ["memberId" .= memberId, "msg" .= msg, "msgTs" .= msgTs] XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2e08afeaef..c11df38dba 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -192,7 +192,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView - CRItemsReadForChat u chatId -> ttyUser u ["items read for chat"] + CRItemsReadForChat u _chatId -> ttyUser u ["items read for chat"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index ea83a10cba..591a8fb311 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -195,6 +195,7 @@ chatGroupTests = do describe "group scoped messages" $ do it "should send scoped messages to support (single moderator)" testScopedSupportSingleModerator it "should send scoped messages to support (many moderators)" testScopedSupportManyModerators + it "should send messages to admins and members" testSupportCLISendCommand it "should correctly maintain unread stats for support chats" testScopedSupportUnreadStats testGroupCheckMessages :: HasCallStack => TestParams -> IO () @@ -3009,7 +3010,7 @@ testGLinkApproveMember = alice <# "#team (support: cath) cath> proofs" -- accept member - alice ##> "/_accept member #1 3 member" + alice ##> "/accept member #team cath" concurrentlyN_ [ alice <## "#team: cath accepted", cath @@ -6980,6 +6981,27 @@ testScopedSupportManyModerators = cath ##> "/member support chats #team" cath <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" +testSupportCLISendCommand :: HasCallStack => TestParams -> IO () +testSupportCLISendCommand = + testChat2 aliceProfile bobProfile $ \alice bob -> do + createGroup2' "team" alice (bob, GRObserver) True + + alice #> "#team 1" + bob <# "#team alice> 1" + + bob ##> "#team 2" + bob <## "#team: you don't have permission to send messages" + (alice "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + + bob #> "#team (support) 4" + alice <# "#team (support: bob) bob> 4" + + bob ##> "#team (support 4" + bob <## "bad chat command: Failed reading: empty" + testScopedSupportUnreadStats :: HasCallStack => TestParams -> IO () testScopedSupportUnreadStats = testChatOpts4 opts aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index a54a9dd36e..61e75d116b 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -292,10 +292,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ==# XGrpDel it "x.grp.direct.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - #==# XGrpDirectInv testConnReq (Just $ MCText "hello") + #==# XGrpDirectInv testConnReq (Just $ MCText "hello") Nothing it "x.grp.direct.inv without content" $ "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" - #==# XGrpDirectInv testConnReq Nothing + #==# XGrpDirectInv testConnReq Nothing Nothing -- it "x.grp.msg.forward" -- $ "{\"v\":\"1\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" -- #==# XGrpMsgForward From fa81df24eeb5e39c1cff1d21b031a800e957bbfb Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 May 2025 11:31:10 +0000 Subject: [PATCH 50/72] core, ui: allow files and links in support scope (#5925) --- .../Chat/ComposeMessage/ComposeView.swift | 7 +- .../simplex/common/views/chat/ComposeView.kt | 9 ++- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 10 +-- src/Simplex/Chat/Library/Subscriber.hs | 80 +++++++++---------- 5 files changed, 55 insertions(+), 53 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 7111524118..7814d962eb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -380,8 +380,8 @@ struct ComposeView: View { Divider() } // preference checks should match checks in forwarding list - let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) - let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) + let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) + let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) if simplexLinkProhibited { msgNotAllowedView("SimpleX links not allowed", icon: "link") @@ -411,7 +411,8 @@ struct ComposeView: View { .padding(.bottom, 16) .padding(.leading, 12) .tint(theme.colors.primary) - if case let .group(g, _) = chat.chatInfo, + if im.secondaryIMFilter == nil, + case let .group(g, _) = chat.chatInfo, !g.fullGroupPreferences.files.on(for: g.membership) { b.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( 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 f377540a95..d3bc7bb824 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 @@ -1039,8 +1039,8 @@ fun ComposeView( if (ctx is ComposeContextItem.ReportedItem) { ReportReasonView(ctx.reason) } - val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) - val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) + val simplexLinkProhibited = chatsCtx.secondaryContextFilter == null && hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) + val fileProhibited = chatsCtx.secondaryContextFilter == null && composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { if (simplexLinkProhibited) { @@ -1069,7 +1069,10 @@ fun ComposeView( Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { Divider() Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) + val isGroupAndProhibitedFiles = + chatsCtx.secondaryContextFilter == null + && chat.chatInfo is ChatInfo.Group + && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) val attachmentClicked = if (isGroupAndProhibitedFiles) { { AlertManager.shared.showAlertMsg( diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7e0ff3b4c7..9c1b31efb3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3386,7 +3386,7 @@ processChatCommand' vr = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership mc ft fileSource True <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 6200fb2435..d3a0e6e59f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -323,12 +323,12 @@ quoteContent mc qmc ciFile_ qFileName = maybe qText (T.pack . getFileName) ciFile_ qTextOrFile = if T.null qText then qFileName else qText -prohibitedGroupContent :: GroupInfo -> GroupMember -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature -prohibitedGroupContent gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}} m mc ft file_ sent +prohibitedGroupContent :: GroupInfo -> GroupMember -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature +prohibitedGroupContent gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice - | not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles - | isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports - | prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks + | isNothing scopeInfo && not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles + | isNothing scopeInfo && isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports + | isNothing scopeInfo && prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks | otherwise = Nothing where -- admins cannot send reports, non-admins cannot receive reports diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e554ed4424..80e287f414 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1648,62 +1648,60 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = e -> throwError e newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM () - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded - | blockedByAdmin m = createBlockedByAdmin - | otherwise = case prohibitedGroupContent gInfo m content ft_ fInv_ False of - Just f -> rejected f - Nothing -> - withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration ciModeration - withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ - Nothing -> createContentItem + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + if blockedByAdmin m' + then createBlockedByAdmin gInfo' m' scopeInfo + else + case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of + Just f -> rejected gInfo' m' scopeInfo f + Nothing -> + withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration gInfo' m' scopeInfo ciModeration + withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ + Nothing -> createContentItem gInfo' m' scopeInfo where - rejected f = newChatItem (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False - timed' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo itemTTL + rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False + timed' gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL live' = fromMaybe False live_ ExtMsgContent content mentions fInv_ itemTTL live_ msgScope_ = mcExtMsgContent mc ts@(_, ft_) = msgContentTexts content - saveRcvCI gInfo' scopeInfo m' = saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg sharedMsgId_ brokerTs - createBlockedByAdmin - | groupFeatureAllowed SGFFullDelete gInfo = do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + saveRcvCI gInfo' m' scopeInfo = saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg sharedMsgId_ brokerTs + createBlockedByAdmin gInfo' m' scopeInfo + | groupFeatureAllowed SGFFullDelete gInfo' = do -- ignores member role when blocked by admin - (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvBlocked) Nothing (timed' gInfo') False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo' ci brokerTs groupMsgToView cInfo ci' | otherwise = do - file_ <- processFileInv - (ci, cInfo) <- createNonLive file_ - ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci + file_ <- processFileInv m' + (ci, cInfo) <- createNonLive gInfo' m' scopeInfo file_ + ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo' ci groupMsgToView cInfo ci' - applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} + applyModeration gInfo' m' scopeInfo CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = - createContentItem - | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty + createContentItem gInfo' m' scopeInfo + | groupFeatureMemberAllowed SGFFullDelete moderator gInfo' = do + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvModerated) Nothing (timed' gInfo') False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo' ci moderator moderatedAt groupMsgToView cInfo ci' | otherwise = do - (gInfo', _m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - file_ <- processFileInv - (ci, _cInfo) <- createNonLive file_ + file_ <- processFileInv m' + (ci, _cInfo) <- createNonLive gInfo' m' scopeInfo file_ deletions <- markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt toView $ CEvtChatItemsDeleted user deletions False False - createNonLive file_ = do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - saveRcvCI gInfo' scopeInfo m' (CIRcvMsgContent content, ts) (snd <$> file_) timed' False mentions - createContentItem = do - file_ <- processFileInv - newChatItem (CIRcvMsgContent content, ts) (snd <$> file_) timed' live' - when (showMessages $ memberSettings m) $ autoAcceptFile file_ - processFileInv = - processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m - newChatItem ciContent ciFile_ timed_ live = do - let mentions' = if showMessages (memberSettings m) then mentions else [] - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' ciContent ciFile_ timed_ live mentions' + createNonLive gInfo' m' scopeInfo file_ = do + saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') False mentions + createContentItem gInfo' m' scopeInfo = do + file_ <- processFileInv m' + newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') live' + when (showMessages $ memberSettings m') $ autoAcceptFile file_ + processFileInv m' = + processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m' + newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed_ live = do + let mentions' = if showMessages (memberSettings m') then mentions else [] + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed_ live mentions' ci' <- blockedMember m' ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo' ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ groupMsgToView cInfo ci' {reactions} From ca9f6267e4b41c0e2db829b36d54f5863c0b2871 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 May 2025 17:05:07 +0000 Subject: [PATCH 51/72] ui: check member supports support chat (#5926) --- apps/ios/SimpleXChat/ChatTypes.swift | 15 ++++++-- .../chat/simplex/common/model/ChatModel.kt | 36 ++++++++++++++----- .../chat/simplex/common/model/SimpleXAPI.kt | 3 ++ .../commonMain/resources/MR/base/strings.xml | 1 + 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e066e1fba0..9a82c912dd 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -15,6 +15,9 @@ public let CREATE_MEMBER_CONTACT_VERSION = 2 // version to receive reports (MCReport) public let REPORTS_VERSION = 12 +// support group knocking (MsgScope) +public let GROUP_KNOCKING_VERSION = 15 + public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")! public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { @@ -1346,11 +1349,19 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { return nil case let .group(groupInfo, groupChatScope): if groupInfo.membership.memberActive { - if groupChatScope == nil { + switch(groupChatScope) { + case .none: if groupInfo.membership.memberPending { return ("reviewed by admins", "Please contact group admin.") } if groupInfo.membership.memberRole == .observer { return ("you are observer", "Please contact group admin.") } + return nil + case let .some(.memberSupport(groupMember_: .some(supportMember))): + if supportMember.versionRange.maxVersion < GROUP_KNOCKING_VERSION && !supportMember.memberPending { + return ("member has old version", nil) + } + return nil + case .some(.memberSupport(groupMember_: .none)): + return nil } - return nil } else { switch groupInfo.membership.memberStatus { case .memRejected: return ("request to join rejected", nil) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 7bd9a1844a..270b3a73b2 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 @@ -1500,15 +1500,29 @@ sealed class ChatInfo: SomeChat, NamedChat { } is Group -> { if (groupInfo.membership.memberActive) { - if (groupChatScope == null) { - if (groupInfo.membership.memberPending) { - return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc) - } - if (groupInfo.membership.memberRole == GroupMemberRole.Observer) { - return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + when (groupChatScope) { + null -> { + if (groupInfo.membership.memberPending) { + return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc) + } + if (groupInfo.membership.memberRole == GroupMemberRole.Observer) { + return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + } + return null } + is GroupChatScopeInfo.MemberSupport -> + if (groupChatScope.groupMember_ != null) { + if ( + groupChatScope.groupMember_.versionRange.maxVersion < GROUP_KNOCKING_VERSION + && !groupChatScope.groupMember_.memberPending + ) { + return generalGetString(MR.strings.cant_send_message_member_has_old_version) to null + } + return null + } else { + return null + } } - return null } else { return when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemRejected -> generalGetString(MR.strings.cant_send_message_rejected) to null @@ -2005,7 +2019,8 @@ data class GroupMember ( val memberContactId: Long? = null, val memberContactProfileId: Long, var activeConn: Connection? = null, - val supportChat: GroupSupportChat? = null + val supportChat: GroupSupportChat? = null, + val memberChatVRange: VersionRange ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -2119,6 +2134,8 @@ data class GroupMember ( && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive } + val versionRange: VersionRange = activeConn?.peerChatVRange ?: memberChatVRange + val memberIncognito = memberProfile.profileId != memberContactProfileId companion object { @@ -2136,7 +2153,8 @@ data class GroupMember ( memberProfile = LocalProfile.sampleData, memberContactId = 1, memberContactProfileId = 1L, - activeConn = Connection.sampleData + activeConn = Connection.sampleData, + memberChatVRange = VersionRange(minVersion = 1, maxVersion = 15) ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 12c93888ee..8378b6fa3b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -58,6 +58,9 @@ typealias ChatCtrl = Long // version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) val CREATE_MEMBER_CONTACT_VERSION = 2 +// support group knocking (MsgScope) +val GROUP_KNOCKING_VERSION = 15 + enum class CallOnLockScreen { DISABLE, SHOW, 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 6198207991..b130970467 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -526,6 +526,7 @@ can\'t send messages you are observer reviewed by admins + member has old version Image From 1a80ecfc2951059cbc3ea89fe51ccc83b975e702 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 20 May 2025 09:07:44 +0000 Subject: [PATCH 52/72] core, ui: allow to delete member support chat; rename reject action (#5927) --- apps/ios/Shared/Model/AppAPITypes.swift | 6 +++ apps/ios/Shared/Model/SimpleXAPI.swift | 6 +++ .../ContextPendingMemberActionsView.swift | 15 +++++-- .../Views/Chat/Group/MemberSupportView.swift | 44 ++++++++++++++----- .../chat/simplex/common/model/SimpleXAPI.kt | 13 ++++++ .../ComposeContextPendingMemberActionsView.kt | 21 ++++++--- .../views/chat/group/GroupMemberInfoView.kt | 35 +++++++-------- .../views/chat/group/MemberSupportView.kt | 38 ++++++++++++---- .../commonMain/resources/MR/base/strings.xml | 6 ++- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Library/Commands.hs | 7 +++ src/Simplex/Chat/Store/Groups.hs | 31 +++++++++++++ .../SQLite/Migrations/chat_query_plans.txt | 28 ++++++++++++ src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Groups.hs | 7 +++ 15 files changed, 212 insertions(+), 48 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index e93814ef84..2ddaf1d2af 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -69,6 +69,7 @@ enum ChatCommand: ChatCmdProtocol { case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) + case apiDeleteMemberSupportChat(groupId: Int64, groupMemberId: Int64) case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool) @@ -250,6 +251,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" + case let .apiDeleteMemberSupportChat(groupId, groupMemberId): return "/_delete member chat #\(groupId) \(groupMemberId)" case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)" case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))" case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))" @@ -425,6 +427,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" case .apiAcceptMember: return "apiAcceptMember" + case .apiDeleteMemberSupportChat: return "apiDeleteMemberSupportChat" case .apiMembersRole: return "apiMembersRole" case .apiBlockMembersForAll: return "apiBlockMembersForAll" case .apiRemoveMembers: return "apiRemoveMembers" @@ -851,6 +854,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: SimpleXChat.Group) case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) case groupUpdated(user: UserRef, toGroup: GroupInfo) @@ -900,6 +904,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .leftMemberUser: "leftMemberUser" case .groupMembers: "groupMembers" case .memberAccepted: "memberAccepted" + case .memberSupportChatDeleted: "memberSupportChatDeleted" case .membersRoleUser: "membersRoleUser" case .membersBlockedForAllUser: "membersBlockedForAllUser" case .groupUpdated: "groupUpdated" @@ -945,6 +950,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3363bf184a..8621baaade 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1646,6 +1646,12 @@ func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: Gro throw r.unexpected } +func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupInfo, GroupMember) { + let r: ChatResponse2 = try await chatSendCmd(.apiDeleteMemberSupportChat(groupId: groupId, groupMemberId: groupMemberId)) + if case let .memberSupportChatDeleted(_, groupInfo, member) = r { return (groupInfo, member) } + throw r.unexpected +} + func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (GroupInfo, [GroupMember]) { let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index 5f78581360..96915b342f 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -18,13 +18,13 @@ struct ContextPendingMemberActionsView: View { var body: some View { HStack(spacing: 0) { ZStack { - Text("Remove") + Text("Reject") .foregroundColor(.red) } .frame(maxWidth: .infinity) .contentShape(Rectangle()) .onTapGesture { - showRemoveMemberAlert(groupInfo, member, dismiss: dismiss) + showRejectMemberAlert(groupInfo, member, dismiss: dismiss) } ZStack { @@ -43,6 +43,15 @@ struct ContextPendingMemberActionsView: View { } } +func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { + showAlert( + title: NSLocalizedString("Reject member?", comment: "alert title"), + buttonTitle: "Reject", + buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + cancelButton: true + ) +} + func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { showAlert( NSLocalizedString("Accept member", comment: "alert title"), @@ -75,7 +84,7 @@ func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMe do { let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember) + _ = ChatModel.shared.upsertGroupMember(gInfo, acceptedMember) ChatModel.shared.updateGroup(gInfo) dismiss?() } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index cd053cc9c8..0bcf09aabd 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -100,14 +100,14 @@ struct MemberSupportView: View { Label("Accept", systemImage: "checkmark") } .tint(theme.colors.primary) + } else { + Button { + showDeleteMemberSupportChatAlert(groupInfo, memberWithChat.wrapped) + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) } - - Button { - showRemoveMemberAlert(groupInfo, memberWithChat.wrapped) - } label: { - Label("Remove", systemImage: "trash") - } - .tint(.red) } } } @@ -248,15 +248,37 @@ struct MemberSupportView: View { } } -func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) { +func showDeleteMemberSupportChatAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { showAlert( - title: NSLocalizedString("Remove member?", comment: "alert title"), - buttonTitle: "Remove", - buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + title: NSLocalizedString("Delete chat with member?", comment: "alert title"), + buttonTitle: "Delete", + buttonAction: { deleteMemberSupportChat(groupInfo, member) }, cancelButton: true ) } +func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) { + Task { + do { + let (gInfo, updatedMember) = try await apiDeleteMemberSupportChat(groupInfo.groupId, member.groupMemberId) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + ChatModel.shared.updateGroup(gInfo) + } + // TODO member row doesn't get removed from list (upsertGroupMember correctly sets supportChat to nil) - this repopulates list to fix it + await ChatModel.shared.loadGroupMembers(gInfo) + } catch let error { + logger.error("apiDeleteMemberSupportChat error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error deleting member support chat", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + #Preview { MemberSupportView( groupInfo: GroupInfo.sampleData, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 8378b6fa3b..c3b681dbc7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1914,6 +1914,13 @@ object ChatController { return null } + suspend fun apiDeleteMemberSupportChat(rh: Long?, groupId: Long, groupMemberId: Long): Pair? { + val r = sendCmd(rh, CC.ApiDeleteMemberSupportChat(groupId, groupMemberId)) + if (r is API.Result && r.res is CR.MemberSupportChatDeleted) return r.res.groupInfo to r.res.member + apiErrorAlert("apiDeleteMemberSupportChat", generalGetString(MR.strings.error_deleting_member_support_chat), r) + return null + } + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): Pair>? { val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members @@ -3345,6 +3352,7 @@ sealed class CC { class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() + class ApiDeleteMemberSupportChat(val groupId: Long, val groupMemberId: Long): CC() class ApiMembersRole(val groupId: Long, val memberIds: List, val memberRole: GroupMemberRole): CC() class ApiBlockMembersForAll(val groupId: Long, val memberIds: List, val blocked: Boolean): CC() class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean): CC() @@ -3531,6 +3539,7 @@ sealed class CC { is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" + is ApiDeleteMemberSupportChat -> "/_delete member chat #$groupId $groupMemberId" is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}" @@ -3695,6 +3704,7 @@ sealed class CC { is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiAcceptMember -> "apiAcceptMember" + is ApiDeleteMemberSupportChat -> "apiDeleteMemberSupportChat" is ApiMembersRole -> "apiMembersRole" is ApiBlockMembersForAll -> "apiBlockMembersForAll" is ApiRemoveMembers -> "apiRemoveMembers" @@ -5849,6 +5859,7 @@ sealed class CR { @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberSupportChatDeleted") class MemberSupportChatDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @Serializable @SerialName("membersRoleUser") class MembersRoleUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val toRole: GroupMemberRole): CR() @@ -6027,6 +6038,7 @@ sealed class CR { is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" + is MemberSupportChatDeleted -> "memberSupportChatDeleted" is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" is MembersRoleUser -> "membersRoleUser" @@ -6198,6 +6210,7 @@ sealed class CR { is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberSupportChatDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 401509a171..3c3f99ad94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.group.removeMember import chat.simplex.common.views.chat.group.removeMemberDialog import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -44,12 +45,12 @@ fun ComposeContextPendingMemberActionsView( .fillMaxHeight() .weight(1F) .clickable { - removeMemberDialog(rhId, groupInfo, member, chatModel, close = { ModalManager.end.closeModal() }) + rejectMemberDialog(rhId, member, chatModel, close = { ModalManager.end.closeModal() }) }, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(MR.strings.remove_pending_member_button), color = Color.Red) + Text(stringResource(MR.strings.reject_pending_member_button), color = Color.Red) } Column( @@ -69,6 +70,17 @@ fun ComposeContextPendingMemberActionsView( } } +fun rejectMemberDialog(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.reject_pending_member_alert_title), + confirmText = generalGetString(MR.strings.reject_pending_member_button), + onConfirm = { + removeMember(rhId, member, chatModel, close) + }, + destructive = true, + ) +} + fun acceptMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_pending_member_alert_title), @@ -105,12 +117,9 @@ private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, val r = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) if (r != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, r.second) + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) chatModel.chatsContext.updateGroup(rhId, r.first) } - withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, r.second) - } } close?.invoke() } 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 0ce0f8fa3c..e56bc36562 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 @@ -245,29 +245,28 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c text = generalGetString(messageId), confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { - withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) - if (r != null) { - val (updatedGroupInfo, removedMembers) = r - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) - removedMembers.forEach { removedMember -> - chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) - } - } - withContext(Dispatchers.Main) { - removedMembers.forEach { removedMember -> - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, removedMember) - } - } - } - close?.invoke() - } + removeMember(rhId, member, chatModel, close) }, destructive = true, ) } +fun removeMember(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { + withBGApi { + val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + if (r != null) { + val (updatedGroupInfo, removedMembers) = r + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) + removedMembers.forEach { removedMember -> + chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) + } + } + } + close?.invoke() + } +} + @Composable fun GroupMemberInfoLayout( rhId: Long?, 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 0ef63a2a11..298a545c8c 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 @@ -28,7 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource -import kotlinx.coroutines.launch +import kotlinx.coroutines.* @Composable fun ModalData.MemberSupportView( @@ -269,14 +269,34 @@ private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupIn acceptMemberDialog(rhId, groupInfo, member) showMenu.value = false }) + } else { + ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + deleteMemberSupportChatDialog(rhId, groupInfo, member) + showMenu.value = false + }) + } + } +} + +fun deleteMemberSupportChatDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.delete_member_support_chat_alert_title), + confirmText = generalGetString(MR.strings.delete_member_support_chat_button), + onConfirm = { + deleteMemberSupportChat(rhId, groupInfo, member) + }, + destructive = true, + ) +} + +private fun deleteMemberSupportChat(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + withBGApi { + val r = chatModel.controller.apiDeleteMemberSupportChat(rhId, groupInfo.groupId, member.groupMemberId) + if (r != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) + } } - ItemAction(stringResource(MR.strings.remove_pending_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { - removeMemberDialog(rhId, groupInfo, member, chatModel) - showMenu.value = false - }) - // TODO [knocking] mark read, mark unread - // ItemAction(stringResource(MR.strings.mark_unread), painterResource(MR.images.ic_mark_chat_unread), onClick = { - // showMenu.value = false - // }) } } 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 b130970467..7bb6562a3f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -159,6 +159,7 @@ Error adding member(s) Error joining group Error accepting member + Error deleting member support chat Cannot receive file Sender cancelled file transfer. Unknown servers! @@ -2188,10 +2189,13 @@ Chats with members No chats with members + Delete chat + Delete chat with member? Chat with admins - Remove + Reject + Reject member? Accept Accept member Member will join the group, accept member? diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 36d6b0aa4a..753e4543d6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -360,6 +360,7 @@ data ChatCommand | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIAcceptMember GroupId GroupMemberId GroupMemberRole + | APIDeleteMemberSupportChat GroupId GroupMemberId | APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole | APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool | APIRemoveMembers {groupId :: GroupId, groupMemberIds :: Set GroupMemberId, withMessages :: Bool} @@ -704,6 +705,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9c1b31efb3..79714af0ec 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2098,6 +2098,12 @@ processChatCommand' vr = \case Just c | connReady c -> GSMemConnected _ -> 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 + when (isNothing $ supportChat m) $ throwCmdError "member has no support chat" + when (memberPending m) $ throwCmdError "member is pending" + (gInfo', m') <- withFastStore' $ \db -> deleteGroupMemberSupportChat db user gInfo m + pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId . procCmd $ do g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId @@ -4106,6 +4112,7 @@ chatCommandP = "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI "/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), + "/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal), "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* " blocked=" <*> onOffP), "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)), diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 68fdb5c0be..4470641491 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -80,6 +80,7 @@ module Simplex.Chat.Store.Groups updateGroupMemberStatus, updateGroupMemberStatusById, updateGroupMemberAccepted, + deleteGroupMemberSupportChat, updateGroupMembersRequireAttention, decreaseGroupMembersRequireAttention, increaseGroupMembersRequireAttention, @@ -1231,6 +1232,36 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status (status, role, currentTs, userId, groupMemberId) pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} +deleteGroupMemberSupportChat :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) +deleteGroupMemberSupportChat db user@User {userId} g@GroupInfo {groupId, membersRequireAttention} m@GroupMember {groupMemberId, supportChat} = do + let requiredAttention = gmRequiresAttention m + currentTs <- getCurrentTime + DB.execute + db + [sql| + DELETE FROM chat_items + WHERE group_scope_group_member_id = ? + |] + (Only groupMemberId) + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = NULL, + support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0, + support_chat_last_msg_from_member_ts = NULL, + updated_at = ? + WHERE group_member_id = ? + |] + (currentTs, groupMemberId) + let m' = m {supportChat = Nothing, updatedAt = currentTs} + g' <- if requiredAttention + then decreaseGroupMembersRequireAttention db user g + else pure g + pure (g', m') + updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo updateGroupMembersRequireAttention db user g member member' | nowRequires && not didRequire = 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 44b915a8fe..cf3c3fb206 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3482,6 +3482,21 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + DELETE FROM chat_items + WHERE group_scope_group_member_id = ? + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id (chat_item_id=?) +SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +SEARCH calls USING COVERING INDEX idx_calls_chat_item_id (chat_item_id=?) +SEARCH chat_item_messages USING COVERING INDEX sqlite_autoindex_chat_item_messages_2 (chat_item_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd_from_chat_item_id=?) +SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) + Query: DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? @@ -4405,6 +4420,19 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_ts = NULL, + support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0, + support_chat_last_msg_from_member_ts = NULL, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_profiles SET preferences = ?, updated_at = ? diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c11df38dba..c13b164693 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -222,6 +222,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m + CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"] CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 591a8fb311..d1588ce6fb 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3118,6 +3118,10 @@ testGLinkReviewMember = (bob "/_delete member chat #1 5" + alice <## "bad chat command: member is pending" + -- accept member dan ##> "/_accept member #1 5 member" concurrentlyN_ @@ -6923,6 +6927,9 @@ testScopedSupportSingleModerator = cath ##> "/_send #1(_support:3) text 5" cath <## "#team: you have insufficient permissions for this action, the required role is moderator" + alice ##> "/_delete member chat #1 2" + alice <## "#team: bob support chat deleted" + testScopedSupportManyModerators :: HasCallStack => TestParams -> IO () testScopedSupportManyModerators = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do From b441527f7d891e58189eb0f06579bdc9be2b5001 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 20 May 2025 12:46:52 +0100 Subject: [PATCH 53/72] ui: translations (#5929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) Currently translated at 100.0% (2054 of 2054 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2054 of 2054 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (2356 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2356 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2054 of 2054 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2356 of 2356 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2389 of 2389 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2054 of 2054 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2389 of 2389 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2054 of 2054 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2389 of 2389 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2389 of 2389 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2389 of 2389 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2054 of 2054 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2053 of 2053 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (2389 of 2389 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (German) Currently translated at 100.0% (2053 of 2053 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2389 of 2389 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * process localizations --------- Co-authored-by: mlanp Co-authored-by: Random Co-authored-by: Bezruchenko Simon Co-authored-by: summoner001 Co-authored-by: Rafi Co-authored-by: Hosted Weblate Co-authored-by: 大王叫我来巡山 --- .../bg.xcloc/Localized Contents/bg.xliff | 110 ++++++++++++- .../cs.xcloc/Localized Contents/cs.xliff | 110 ++++++++++++- .../de.xcloc/Localized Contents/de.xliff | 113 +++++++++++++- .../en.xcloc/Localized Contents/en.xliff | 135 +++++++++++++++- .../es.xcloc/Localized Contents/es.xliff | 110 ++++++++++++- .../fi.xcloc/Localized Contents/fi.xliff | 110 ++++++++++++- .../fr.xcloc/Localized Contents/fr.xliff | 110 ++++++++++++- .../hu.xcloc/Localized Contents/hu.xliff | 145 +++++++++++++++--- .../it.xcloc/Localized Contents/it.xliff | 111 +++++++++++++- .../ja.xcloc/Localized Contents/ja.xliff | 110 ++++++++++++- .../nl.xcloc/Localized Contents/nl.xliff | 110 ++++++++++++- .../pl.xcloc/Localized Contents/pl.xliff | 110 ++++++++++++- .../ru.xcloc/Localized Contents/ru.xliff | 110 ++++++++++++- .../th.xcloc/Localized Contents/th.xliff | 110 ++++++++++++- .../tr.xcloc/Localized Contents/tr.xliff | 110 ++++++++++++- .../uk.xcloc/Localized Contents/uk.xliff | 110 ++++++++++++- .../Localized Contents/zh-Hans.xliff | 110 ++++++++++++- apps/ios/bg.lproj/Localizable.strings | 17 +- apps/ios/cs.lproj/Localizable.strings | 17 +- apps/ios/de.lproj/Localizable.strings | 22 +-- apps/ios/es.lproj/Localizable.strings | 19 ++- apps/ios/fi.lproj/Localizable.strings | 17 +- apps/ios/fr.lproj/Localizable.strings | 19 ++- apps/ios/hu.lproj/Localizable.strings | 54 +++---- apps/ios/it.lproj/Localizable.strings | 22 +-- apps/ios/ja.lproj/Localizable.strings | 17 +- apps/ios/nl.lproj/Localizable.strings | 19 ++- apps/ios/pl.lproj/Localizable.strings | 19 ++- apps/ios/ru.lproj/Localizable.strings | 15 +- apps/ios/th.lproj/Localizable.strings | 17 +- apps/ios/tr.lproj/Localizable.strings | 19 ++- apps/ios/uk.lproj/Localizable.strings | 19 ++- apps/ios/zh-Hans.lproj/Localizable.strings | 17 +- .../commonMain/resources/MR/de/strings.xml | 35 ++++- .../commonMain/resources/MR/hu/strings.xml | 71 ++++++--- .../commonMain/resources/MR/in/strings.xml | 2 +- .../commonMain/resources/MR/it/strings.xml | 33 ++++ .../commonMain/resources/MR/uk/strings.xml | 2 +- .../resources/MR/zh-rCN/strings.xml | 33 ++++ 39 files changed, 2163 insertions(+), 276 deletions(-) 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 776199ac1f..7f38695470 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -554,6 +554,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Приеми условията @@ -575,6 +583,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Приети условия @@ -1535,11 +1547,23 @@ set passcode view Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Чатове No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -2698,7 +2722,7 @@ swipe action Don't show again Не показвай отново - No comment provided by engineer. + alert action Done @@ -3006,6 +3030,10 @@ chat item action Грешка при приемане на заявка за контакт No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Грешка при добавяне на член(ове) @@ -3196,7 +3224,7 @@ chat item action Error removing member Грешка при отстраняване на член - No comment provided by engineer. + alert title Error reordering lists @@ -4508,6 +4536,10 @@ This is your link for group %@! Член No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text @@ -4539,6 +4571,10 @@ This is your link for group %@! Членът ще бъде премахнат от групата - това не може да бъде отменено! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Членовете на групата могат да добавят реакции към съобщенията. @@ -4931,6 +4967,10 @@ This is your link for group %@! Нова членска роля No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Ново съобщение @@ -4967,6 +5007,10 @@ This is your link for group %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Няма избрани контакти @@ -5140,7 +5184,8 @@ This is your link for group %@! Ok Ок - alert button + alert action +alert button Old database @@ -5531,6 +5576,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -5981,7 +6030,7 @@ swipe action Remove member? Острани член? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6053,6 +6102,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -6157,6 +6210,14 @@ swipe action Review conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Отзови @@ -6210,6 +6271,10 @@ chat item action Запази (и уведоми контактите) alert button + + Save admission settings? + alert title + Save and notify contact Запази и уведоми контакта @@ -6689,6 +6754,10 @@ chat item action Задайте го вместо системната идентификация. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8343,6 +8412,10 @@ Repeat join request? Можете да видите отново линкът за покана в подробностите за връзката. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Не може да изпращате съобщения! @@ -8635,6 +8708,10 @@ Repeat connection request? по-горе, след това избери: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call обаждането прието @@ -8644,6 +8721,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin админ @@ -8664,6 +8745,10 @@ Repeat connection request? съгласуване на криптиране… chat item text + + all + member criteria value + all members всички членове @@ -9226,6 +9311,7 @@ pref value изключено enabled status group pref value +member criteria value time to disappear @@ -9274,6 +9360,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption квантово устойчиво e2e криптиране @@ -9327,6 +9417,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved запазено @@ -9508,6 +9606,10 @@ last received msg: %2$@ вие No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group вие сте поканени в групата 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 0400839cb0..23709c29de 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -544,6 +544,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions No comment provided by engineer. @@ -564,6 +572,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions No comment provided by engineer. @@ -1456,11 +1468,23 @@ set passcode view Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Chaty No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -2584,7 +2608,7 @@ swipe action Don't show again Znovu neukazuj - No comment provided by engineer. + alert action Done @@ -2878,6 +2902,10 @@ chat item action Chyba při přijímání žádosti o kontakt No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Chyba přidávání člena(ů) @@ -3065,7 +3093,7 @@ chat item action Error removing member Chyba při odebrání člena - No comment provided by engineer. + alert title Error reordering lists @@ -4333,6 +4361,10 @@ This is your link for group %@! Člen No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text @@ -4364,6 +4396,10 @@ This is your link for group %@! Člen bude odstraněn ze skupiny - toto nelze vzít zpět! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Členové skupin mohou přidávat reakce na zprávy. @@ -4739,6 +4775,10 @@ This is your link for group %@! Nová role člena No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Nová zpráva @@ -4775,6 +4815,10 @@ This is your link for group %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Nebyl vybrán žádný kontakt @@ -4945,7 +4989,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -5320,6 +5365,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -5762,7 +5811,7 @@ swipe action Remove member? Odebrat člena? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -5829,6 +5878,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -5932,6 +5985,14 @@ swipe action Review conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Odvolat @@ -5984,6 +6045,10 @@ chat item action Uložit (a informovat kontakty) alert button + + Save admission settings? + alert title + Save and notify contact Uložit a upozornit kontakt @@ -6455,6 +6520,10 @@ chat item action Nastavte jej namísto ověřování systému. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8044,6 +8113,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Nemůžete posílat zprávy! @@ -8330,6 +8403,10 @@ Repeat connection request? výše, pak vyberte: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call přijatý hovor @@ -8339,6 +8416,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin správce @@ -8358,6 +8439,10 @@ Repeat connection request? povoluji šifrování… chat item text + + all + member criteria value + all members feature role @@ -8910,6 +8995,7 @@ pref value vypnuto enabled status group pref value +member criteria value time to disappear @@ -8957,6 +9043,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -9007,6 +9097,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -9178,6 +9276,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group jste pozváni do skupiny 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 06fd7c5a1d..f4e5083fb0 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Nutzungsbedingungen akzeptieren @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Akzeptierte Nutzungsbedingungen @@ -1582,11 +1594,23 @@ set passcode view Der Chat wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Chats No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Alle 20min Nachrichten überprüfen. @@ -2825,7 +2849,7 @@ swipe action Don't show again Nicht nochmals anzeigen - No comment provided by engineer. + alert action Done @@ -3143,6 +3167,10 @@ chat item action Fehler beim Annehmen der Kontaktanfrage No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Fehler beim Hinzufügen von Mitgliedern @@ -3346,7 +3374,7 @@ chat item action Error removing member Fehler beim Entfernen des Mitglieds - No comment provided by engineer. + alert title Error reordering lists @@ -4730,6 +4758,10 @@ Das ist Ihr Link für die Gruppe %@! Mitglied No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Mitglied inaktiv @@ -4765,6 +4797,10 @@ Das ist Ihr Link für die Gruppe %@! Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Gruppenmitglieder können eine Reaktion auf Nachrichten geben. @@ -5185,6 +5221,10 @@ Das ist Ihr Link für die Gruppe %@! Neue Mitgliedsrolle No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Neue Nachricht @@ -5225,6 +5265,10 @@ Das ist Ihr Link für die Gruppe %@! Keine Chats in der Liste %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Keine Kontakte ausgewählt @@ -5417,7 +5461,8 @@ Das ist Ihr Link für die Gruppe %@! Ok Ok - alert button + alert action +alert button Old database @@ -5575,6 +5620,7 @@ Dies erfordert die Aktivierung eines VPNs. Open link? + Link öffnen? alert title @@ -5828,6 +5874,10 @@ Fehler: %@ Bitte versuchen Sie, die Benachrichtigungen zu deaktivieren und wieder zu aktivieren. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. Bitte warten Sie, bis die Token-Aktivierung abgeschlossen ist. @@ -6314,7 +6364,7 @@ swipe action Remove member? Das Mitglied entfernen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6391,6 +6441,10 @@ swipe action Grund der Meldung? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. Spam melden: Nur Gruppenmoderatoren werden es sehen. @@ -6506,6 +6560,14 @@ swipe action Nutzungsbedingungen einsehen No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Widerrufen @@ -6562,6 +6624,10 @@ chat item action Speichern (und Kontakte benachrichtigen) alert button + + Save admission settings? + alert title + Save and notify contact Speichern und Kontakt benachrichtigen @@ -7077,6 +7143,10 @@ chat item action Anstelle der System-Authentifizierung festlegen. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. Verfallsdatum von Nachrichten in Chats festlegen. @@ -8839,6 +8909,10 @@ Verbindungsanfrage wiederholen? Den Einladungslink können Sie in den Details der Verbindung nochmals sehen. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Sie können keine Nachrichten versenden! @@ -9078,7 +9152,7 @@ Verbindungsanfrage wiederholen? Your profile is stored on your device and only shared with your contacts. - Das Profil wird nur mit Ihren Kontakten geteilt. + Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt. No comment provided by engineer. @@ -9141,6 +9215,10 @@ Verbindungsanfrage wiederholen? Danach die gewünschte Aktion auswählen: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call Anruf angenommen @@ -9151,6 +9229,10 @@ Verbindungsanfrage wiederholen? Einladung angenommen chat list item title + + accepted you + rcv group event chat item + admin Admin @@ -9171,6 +9253,10 @@ Verbindungsanfrage wiederholen? Verschlüsselung zustimmen… chat item text + + all + member criteria value + all members Alle Mitglieder @@ -9743,6 +9829,7 @@ pref value Aus enabled status group pref value +member criteria value time to disappear @@ -9795,6 +9882,10 @@ time to disappear ausstehende Genehmigung No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption Quantum-resistente E2E-Verschlüsselung @@ -9850,6 +9941,14 @@ time to disappear Zur Verbindung aufgefordert chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved abgespeichert @@ -10039,6 +10138,10 @@ Zuletzt empfangene Nachricht: %2$@ Profil No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group Sie sind zu der Gruppe eingeladen 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 fd71e0dee6..9821e49098 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -565,6 +565,16 @@ time interval accept incoming call via notification swipe action + + Accept as member + Accept as member + alert action + + + Accept as observer + Accept as observer + alert action + Accept conditions Accept conditions @@ -586,6 +596,11 @@ swipe action accept contact request via notification swipe action + + Accept member + Accept member + alert title + Accepted conditions Accepted conditions @@ -1582,11 +1597,26 @@ set passcode view Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + Chat with admins + chat toolbar + + + Chat with member + Chat with member + No comment provided by engineer. + Chats Chats No comment provided by engineer. + + Chats with members + Chats with members + No comment provided by engineer. + Check messages every 20 min. Check messages every 20 min. @@ -2825,7 +2855,7 @@ swipe action Don't show again Don't show again - No comment provided by engineer. + alert action Done @@ -3143,6 +3173,11 @@ chat item action Error accepting contact request No comment provided by engineer. + + Error accepting member + Error accepting member + alert title + Error adding member(s) Error adding member(s) @@ -3346,7 +3381,7 @@ chat item action Error removing member Error removing member - No comment provided by engineer. + alert title Error reordering lists @@ -4730,6 +4765,11 @@ This is your link for group %@! Member No comment provided by engineer. + + Member admission + Member admission + No comment provided by engineer. + Member inactive Member inactive @@ -4765,6 +4805,11 @@ This is your link for group %@! Member will be removed from group - this cannot be undone! No comment provided by engineer. + + Member will join the group, accept member? + Member will join the group, accept member? + alert message + Members can add message reactions. Members can add message reactions. @@ -5185,6 +5230,11 @@ This is your link for group %@! New member role No comment provided by engineer. + + New member wants to join the group. + New member wants to join the group. + rcv group event chat item + New message New message @@ -5225,6 +5275,11 @@ This is your link for group %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No chats with members + No comment provided by engineer. + No contacts selected No contacts selected @@ -5417,7 +5472,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -5829,6 +5885,11 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. Please wait for token activation to complete. @@ -6315,7 +6376,7 @@ swipe action Remove member? Remove member? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6392,6 +6453,11 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + Report sent to moderators + alert title + Report spam: only group moderators will see it. Report spam: only group moderators will see it. @@ -6507,6 +6573,16 @@ swipe action Review conditions No comment provided by engineer. + + Review members + Review members + admission stage + + + Review members before admitting ("knocking"). + Review members before admitting ("knocking"). + admission stage description + Revoke Revoke @@ -6563,6 +6639,11 @@ chat item action Save (and notify contacts) alert button + + Save admission settings? + Save admission settings? + alert title + Save and notify contact Save and notify contact @@ -7078,6 +7159,11 @@ chat item action Set it instead of system authentication. No comment provided by engineer. + + Set member admission + Set member admission + No comment provided by engineer. + Set message expiration in chats. Set message expiration in chats. @@ -8840,6 +8926,11 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + You can view your reports in Chat with admins. + alert message + You can't send messages! You can't send messages! @@ -9142,6 +9233,11 @@ Repeat connection request? above, then choose: No comment provided by engineer. + + accepted %@ + accepted %@ + rcv group event chat item + accepted call accepted call @@ -9152,6 +9248,11 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + accepted you + rcv group event chat item + admin admin @@ -9172,6 +9273,11 @@ Repeat connection request? agreeing encryption… chat item text + + all + all + member criteria value + all members all members @@ -9744,6 +9850,7 @@ pref value off enabled status group pref value +member criteria value time to disappear @@ -9796,6 +9903,11 @@ time to disappear pending approval No comment provided by engineer. + + pending review + pending review + No comment provided by engineer. + quantum resistant e2e encryption quantum resistant e2e encryption @@ -9851,6 +9963,16 @@ time to disappear requested to connect chat list item title + + review + review + No comment provided by engineer. + + + reviewed by admins + reviewed by admins + No comment provided by engineer. + saved saved @@ -10040,6 +10162,11 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + you accepted this member + snd group event chat item + you are invited to group you are invited to group 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 d39fb61249..2a9d2e9430 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Aceptar condiciones @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Condiciones aceptadas @@ -1582,11 +1594,23 @@ set passcode view El chat será eliminado para tí. ¡No puede deshacerse! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Chats No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Comprobar mensajes cada 20 min. @@ -2825,7 +2849,7 @@ swipe action Don't show again No volver a mostrar - No comment provided by engineer. + alert action Done @@ -3143,6 +3167,10 @@ chat item action Error al aceptar solicitud del contacto No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Error al añadir miembro(s) @@ -3346,7 +3374,7 @@ chat item action Error removing member Error al expulsar miembro - No comment provided by engineer. + alert title Error reordering lists @@ -4730,6 +4758,10 @@ This is your link for group %@! Miembro No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Miembro inactivo @@ -4765,6 +4797,10 @@ This is your link for group %@! El miembro será expulsado del grupo. ¡No puede deshacerse! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Los miembros pueden añadir reacciones a los mensajes. @@ -5185,6 +5221,10 @@ This is your link for group %@! Nuevo rol de miembro No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Mensaje nuevo @@ -5225,6 +5265,10 @@ This is your link for group %@! Sin chats en la lista %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Ningún contacto seleccionado @@ -5417,7 +5461,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -5828,6 +5873,10 @@ Error: %@ Por favor, intenta desactivar y reactivar las notificaciones. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. Por favor, espera a que el token de activación se complete. @@ -6314,7 +6363,7 @@ swipe action Remove member? ¿Expulsar miembro? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6391,6 +6440,10 @@ swipe action ¿Motivo del informe? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. Informar de spam: sólo los moderadores del grupo lo verán. @@ -6506,6 +6559,14 @@ swipe action Revisar condiciones No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Revocar @@ -6562,6 +6623,10 @@ chat item action Guardar (y notificar contactos) alert button + + Save admission settings? + alert title + Save and notify contact Guardar y notificar contacto @@ -7077,6 +7142,10 @@ chat item action Úsalo en lugar de la autenticación del sistema. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. Establece el vencimiento para los mensajes en los chats. @@ -8839,6 +8908,10 @@ Repeat join request? Podrás ver el enlace de invitación en detalles de conexión. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! ¡No puedes enviar mensajes! @@ -9141,6 +9214,10 @@ Repeat connection request? y después elige: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call llamada aceptada @@ -9151,6 +9228,10 @@ Repeat connection request? invitación aceptada chat list item title + + accepted you + rcv group event chat item + admin administrador @@ -9171,6 +9252,10 @@ Repeat connection request? acordando cifrado… chat item text + + all + member criteria value + all members todos los miembros @@ -9743,6 +9828,7 @@ pref value desactivado enabled status group pref value +member criteria value time to disappear @@ -9795,6 +9881,10 @@ time to disappear pendiente de aprobación No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption cifrado e2e resistente a tecnología cuántica @@ -9850,6 +9940,14 @@ time to disappear solicitado para conectar chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved guardado @@ -10039,6 +10137,10 @@ last received msg: %2$@ tu No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group has sido invitado a un grupo 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 a54666bb10..6e5928281d 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -527,6 +527,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions No comment provided by engineer. @@ -547,6 +555,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions No comment provided by engineer. @@ -1437,11 +1449,23 @@ set passcode view Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Keskustelut No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -2565,7 +2589,7 @@ swipe action Don't show again Älä näytä uudelleen - No comment provided by engineer. + alert action Done @@ -2858,6 +2882,10 @@ chat item action Virhe kontaktipyynnön hyväksymisessä No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Virhe lisättäessä jäseniä @@ -3044,7 +3072,7 @@ chat item action Error removing member Virhe poistettaessa jäsentä - No comment provided by engineer. + alert title Error reordering lists @@ -4311,6 +4339,10 @@ This is your link for group %@! Jäsen No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text @@ -4342,6 +4374,10 @@ This is your link for group %@! Jäsen poistetaan ryhmästä - tätä ei voi perua! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Ryhmän jäsenet voivat lisätä viestireaktioita. @@ -4716,6 +4752,10 @@ This is your link for group %@! Uusi jäsenrooli No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Uusi viesti @@ -4752,6 +4792,10 @@ This is your link for group %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Kontakteja ei ole valittu @@ -4922,7 +4966,8 @@ This is your link for group %@! Ok Ok - alert button + alert action +alert button Old database @@ -5296,6 +5341,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -5738,7 +5787,7 @@ swipe action Remove member? Poista jäsen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -5805,6 +5854,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -5908,6 +5961,14 @@ swipe action Review conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Peruuta @@ -5960,6 +6021,10 @@ chat item action Tallenna (ja ilmoita kontakteille) alert button + + Save admission settings? + alert title + Save and notify contact Tallenna ja ilmoita kontaktille @@ -6430,6 +6495,10 @@ chat item action Aseta se järjestelmän todennuksen sijaan. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8017,6 +8086,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Et voi lähettää viestejä! @@ -8303,6 +8376,10 @@ Repeat connection request? edellä, valitse sitten: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call hyväksytty puhelu @@ -8312,6 +8389,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin ylläpitäjä @@ -8331,6 +8412,10 @@ Repeat connection request? hyväksyy salausta… chat item text + + all + member criteria value + all members feature role @@ -8882,6 +8967,7 @@ pref value pois enabled status group pref value +member criteria value time to disappear @@ -8929,6 +9015,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8979,6 +9069,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -9149,6 +9247,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group sinut on kutsuttu ryhmään 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 59bde0650e..edafb47a53 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Accepter les conditions @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Conditions acceptées @@ -1580,11 +1592,23 @@ set passcode view Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière ! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Discussions No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Consulter les messages toutes les 20 minutes. @@ -2823,7 +2847,7 @@ swipe action Don't show again Ne plus afficher - No comment provided by engineer. + alert action Done @@ -3141,6 +3165,10 @@ chat item action Erreur de validation de la demande de contact No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Erreur lors de l'ajout de membre·s @@ -3344,7 +3372,7 @@ chat item action Error removing member Erreur lors de la suppression d'un membre - No comment provided by engineer. + alert title Error reordering lists @@ -4712,6 +4740,10 @@ Voici votre lien pour le groupe %@ ! Membre No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Membre inactif @@ -4746,6 +4778,10 @@ Voici votre lien pour le groupe %@ ! Ce membre sera retiré du groupe - impossible de revenir en arrière ! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Les membres du groupe peuvent ajouter des réactions aux messages. @@ -5160,6 +5196,10 @@ Voici votre lien pour le groupe %@ ! Nouveau rôle No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Nouveau message @@ -5197,6 +5237,10 @@ Voici votre lien pour le groupe %@ ! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Aucun contact sélectionné @@ -5383,7 +5427,8 @@ Voici votre lien pour le groupe %@ ! Ok Ok - alert button + alert action +alert button Old database @@ -5790,6 +5835,10 @@ Erreur : %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -6267,7 +6316,7 @@ swipe action Remove member? Retirer ce membre ? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6339,6 +6388,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -6449,6 +6502,14 @@ swipe action Vérifier les conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Révoquer @@ -6505,6 +6566,10 @@ chat item action Enregistrer (et en informer les contacts) alert button + + Save admission settings? + alert title + Save and notify contact Enregistrer et en informer le contact @@ -7017,6 +7082,10 @@ chat item action Il permet de remplacer l'authentification du système. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8762,6 +8831,10 @@ Répéter la demande d'adhésion ? Vous pouvez à nouveau consulter le lien d'invitation dans les détails de la connexion. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Vous ne pouvez pas envoyer de messages ! @@ -9063,6 +9136,10 @@ Répéter la demande de connexion ? ci-dessus, puis choisissez : No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call appel accepté @@ -9073,6 +9150,10 @@ Répéter la demande de connexion ? invitation acceptée chat list item title + + accepted you + rcv group event chat item + admin admin @@ -9093,6 +9174,10 @@ Répéter la demande de connexion ? négociation du chiffrement… chat item text + + all + member criteria value + all members tous les membres @@ -9663,6 +9748,7 @@ pref value off enabled status group pref value +member criteria value time to disappear @@ -9713,6 +9799,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chiffrement e2e résistant post-quantique @@ -9767,6 +9857,14 @@ time to disappear demande à se connecter chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved enregistré @@ -9956,6 +10054,10 @@ dernier message reçu : %2$@ vous No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group vous êtes invité·e au groupe 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 78bee138e4..559875b5cd 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Feltételek elfogadása @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Elfogadott feltételek @@ -1582,11 +1594,23 @@ set passcode view A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Csevegések No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Üzenetek ellenőrzése 20 percenként. @@ -2240,7 +2264,7 @@ Ez a saját egyszer használható meghívója! Database IDs and Transport isolation option. - Adatbázis-azonosítók és átvitel-izolációs beállítások. + Adatbázis-azonosítók és átvitelelkülönítési beállítások. No comment provided by engineer. @@ -2338,7 +2362,7 @@ Ez a saját egyszer használható meghívója! Decryption error - Titkosítás visszafejtési hiba + Titkosításvisszafejtési hiba message decrypt error item @@ -2674,7 +2698,7 @@ swipe action Different names, avatars and transport isolation. - Különböző nevek, profilképek és átvitel-izoláció. + Különböző nevek, profilképek és átvitelizoláció. No comment provided by engineer. @@ -2825,7 +2849,7 @@ swipe action Don't show again Ne mutasd újra - No comment provided by engineer. + alert action Done @@ -3143,6 +3167,10 @@ chat item action Hiba történt a meghívási kérés elfogadásakor No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Hiba történt a tag(ok) hozzáadásakor @@ -3265,7 +3293,7 @@ chat item action Error deleting user profile - Hiba történt a felhasználó-profil törlésekor + Hiba történt a felhasználói profil törlésekor No comment provided by engineer. @@ -3346,7 +3374,7 @@ chat item action Error removing member Hiba történt a tag eltávolításakor - No comment provided by engineer. + alert title Error reordering lists @@ -4449,7 +4477,7 @@ További fejlesztések hamarosan! It can happen when you or your connection used the old database backup. - Ez akkor fordulhat elő, ha Ön vagy a partnere régi adatbázis biztonsági mentést használt. + Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta. No comment provided by engineer. @@ -4459,7 +4487,7 @@ További fejlesztések hamarosan! 3. The connection was compromised. Ez akkor fordulhat elő, ha: 1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak. -2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt. +2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere egy régi adatbázis biztonsági mentését használta. 3. A kapcsolat sérült. No comment provided by engineer. @@ -4730,6 +4758,10 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Tag No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Inaktív tag @@ -4765,6 +4797,10 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. A tagok reakciókat adhatnak hozzá az üzenetekhez. @@ -4942,12 +4978,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery. - Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, sérülés utáni titkosságvédelemmel és -helyreállítással, továbbá letagadhatósággal vannak védve. + Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve. No comment provided by engineer. Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery. - Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, sérülés utáni titkosságvédelemmel és -helyreállítással, továbbá letagadhatósággal vannak védve. + Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve. No comment provided by engineer. @@ -5185,6 +5221,10 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Új tag szerepköre No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Új üzenet @@ -5225,6 +5265,10 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Nincsenek csevegések a(z) %@ nevű listában No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Nincs partner kijelölve @@ -5347,7 +5391,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No user identifiers. - Nincsenek felhasználó-azonosítók. + Nincsenek felhasználói azonosítók. No comment provided by engineer. @@ -5417,7 +5461,8 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Ok Rendben - alert button + alert action +alert button Old database @@ -5575,6 +5620,7 @@ VPN engedélyezése szükséges. Open link? + Megnyitja a hivatkozást? alert title @@ -5828,6 +5874,10 @@ Hiba: %@ Próbálja meg letiltani és újra engedélyezni az értesítéseket. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. Várjon, amíg a token aktiválása befejeződik. @@ -6283,12 +6333,12 @@ swipe action Relay server is only used if necessary. Another party can observe your IP address. - A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címet. + A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. No comment provided by engineer. Relay server protects your IP address, but it can observe the duration of the call. - A továbbítókiszolgáló megvédi az Ön IP-címét, de megfigyelheti a hívás időtartamát. + A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. No comment provided by engineer. @@ -6314,7 +6364,7 @@ swipe action Remove member? Eltávolítja a tagot? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6391,6 +6441,10 @@ swipe action Jelentés indoklása? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. Kéretlen tartalom jelentése: csak a csoport moderátorai látják. @@ -6506,6 +6560,14 @@ swipe action Feltételek felülvizsgálata No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Visszavonás @@ -6562,6 +6624,10 @@ chat item action Mentés (és a partnerek értesítése) alert button + + Save admission settings? + alert title + Save and notify contact Mentés és a partner értesítése @@ -7077,6 +7143,10 @@ chat item action Beállítás a rendszer-hitelesítés helyett. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. Üzenetek eltűnési idejének módosítása a csevegésekben. @@ -7335,7 +7405,7 @@ chat item action SimpleX protocols reviewed by Trail of Bits. - A SimpleX Chat biztonsága a Trail of Bits által lett felülvizsgálva. + A SimpleX-protokollokat a Trail of Bits auditálta. No comment provided by engineer. @@ -8027,7 +8097,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Transport isolation - Átvitel-izoláció + Átvitelelkülönítés No comment provided by engineer. @@ -8339,7 +8409,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Use private routing with unknown servers when IP address is not protected. - Használjon privát útválasztást ismeretlen kiszolgálókkal, ha az IP-cím nem védett. + Használjon privát útválasztást az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. No comment provided by engineer. @@ -8624,12 +8694,12 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Without Tor or VPN, your IP address will be visible to file servers. - Tor vagy VPN nélkül az Ön IP-címe látható lesz a fájlkiszolgálók számára. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára. No comment provided by engineer. Without Tor or VPN, your IP address will be visible to these XFTP relays: %@. - Tor vagy VPN nélkül az Ön IP-címe látható lesz a következő XFTP-továbbítókiszolgálók számára: %@. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára: %@. alert message @@ -8839,6 +8909,10 @@ Megismétli a meghívási kérést? A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Nem lehet üzeneteket küldeni! @@ -9078,7 +9152,7 @@ Megismétli a meghívási kérést? Your profile is stored on your device and only shared with your contacts. - A profilja csak a partnereivel van megosztva. + A profilja az eszközén van tárolva és csak a partnereivel van megosztva. No comment provided by engineer. @@ -9141,6 +9215,10 @@ Megismétli a meghívási kérést? gombra fent, majd válassza ki: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call fogadott hívás @@ -9151,6 +9229,10 @@ Megismétli a meghívási kérést? elfogadott meghívó chat list item title + + accepted you + rcv group event chat item + admin adminisztrátor @@ -9171,6 +9253,10 @@ Megismétli a meghívási kérést? titkosítás elfogadása… chat item text + + all + member criteria value + all members összes tag @@ -9743,6 +9829,7 @@ pref value kikapcsolva enabled status group pref value +member criteria value time to disappear @@ -9795,6 +9882,10 @@ time to disappear jóváhagyásra vár No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption végpontok közötti kvantumbiztos titkosítás @@ -9850,6 +9941,14 @@ time to disappear Függőben lévő meghívási kérelem chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved mentett @@ -10039,6 +10138,10 @@ utoljára fogadott üzenet: %2$@ Ön No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group Ön meghívást kapott a csoportba 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 cf5f61918f..e3f29b9ce5 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Accetta le condizioni @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Condizioni accettate @@ -1582,11 +1594,23 @@ set passcode view La chat verrà eliminata solo per te, non è reversibile! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Chat No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Controlla i messaggi ogni 20 min. @@ -2825,7 +2849,7 @@ swipe action Don't show again Non mostrare più - No comment provided by engineer. + alert action Done @@ -3143,6 +3167,10 @@ chat item action Errore nell'accettazione della richiesta di contatto No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Errore di aggiunta membro/i @@ -3346,7 +3374,7 @@ chat item action Error removing member Errore nella rimozione del membro - No comment provided by engineer. + alert title Error reordering lists @@ -4730,6 +4758,10 @@ Questo è il tuo link per il gruppo %@! Membro No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Membro inattivo @@ -4765,6 +4797,10 @@ Questo è il tuo link per il gruppo %@! Il membro verrà rimosso dal gruppo, non è reversibile! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. I membri del gruppo possono aggiungere reazioni ai messaggi. @@ -5185,6 +5221,10 @@ Questo è il tuo link per il gruppo %@! Nuovo ruolo del membro No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Nuovo messaggio @@ -5225,6 +5265,10 @@ Questo è il tuo link per il gruppo %@! Nessuna chat nell'elenco %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Nessun contatto selezionato @@ -5417,7 +5461,8 @@ Questo è il tuo link per il gruppo %@! Ok Ok - alert button + alert action +alert button Old database @@ -5575,6 +5620,7 @@ Richiede l'attivazione della VPN. Open link? + Aprire il link? alert title @@ -5828,6 +5874,10 @@ Errore: %@ Prova a disattivare e riattivare le notifiche. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. Attendi il completamento dell'attivazione del token. @@ -6314,7 +6364,7 @@ swipe action Remove member? Rimuovere il membro? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6391,6 +6441,10 @@ swipe action Motivo della segnalazione? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. Segnala spam: solo i moderatori del gruppo lo vedranno. @@ -6506,6 +6560,14 @@ swipe action Leggi le condizioni No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Revoca @@ -6562,6 +6624,10 @@ chat item action Salva (e avvisa i contatti) alert button + + Save admission settings? + alert title + Save and notify contact Salva e avvisa il contatto @@ -7077,6 +7143,10 @@ chat item action Impostalo al posto dell'autenticazione di sistema. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. Imposta la scadenza dei messaggi nelle chat. @@ -8839,6 +8909,10 @@ Ripetere la richiesta di ingresso? Puoi vedere di nuovo il link di invito nei dettagli di connessione. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Non puoi inviare messaggi! @@ -9141,6 +9215,10 @@ Ripetere la richiesta di connessione? sopra, quindi scegli: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call chiamata accettata @@ -9151,6 +9229,10 @@ Ripetere la richiesta di connessione? invito accettato chat list item title + + accepted you + rcv group event chat item + admin amministratore @@ -9171,6 +9253,10 @@ Ripetere la richiesta di connessione? concordando la crittografia… chat item text + + all + member criteria value + all members tutti i membri @@ -9743,6 +9829,7 @@ pref value off enabled status group pref value +member criteria value time to disappear @@ -9795,6 +9882,10 @@ time to disappear in attesa di approvazione No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption crittografia e2e resistente alla quantistica @@ -9850,6 +9941,14 @@ time to disappear richiesto di connettersi chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved salvato @@ -10039,6 +10138,10 @@ ultimo msg ricevuto: %2$@ tu No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group sei stato/a invitato/a al gruppo 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 27134216a7..ab4dd08133 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -561,6 +561,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions No comment provided by engineer. @@ -581,6 +589,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions No comment provided by engineer. @@ -1488,11 +1500,23 @@ set passcode view Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats チャット No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -2637,7 +2661,7 @@ swipe action Don't show again 次から表示しない - No comment provided by engineer. + alert action Done @@ -2931,6 +2955,10 @@ chat item action 連絡先リクエストの承諾にエラー発生 No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) メンバー追加にエラー発生 @@ -3117,7 +3145,7 @@ chat item action Error removing member メンバー除名にエラー発生 - No comment provided by engineer. + alert title Error reordering lists @@ -4384,6 +4412,10 @@ This is your link for group %@! メンバー No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text @@ -4415,6 +4447,10 @@ This is your link for group %@! メンバーをグループから除名する (※元に戻せません※)! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. グループメンバーはメッセージへのリアクションを追加できます。 @@ -4792,6 +4828,10 @@ This is your link for group %@! 新しいメンバーの役割 No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message 新しいメッセージ @@ -4828,6 +4868,10 @@ This is your link for group %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected 連絡先が選択されてません @@ -4998,7 +5042,8 @@ This is your link for group %@! Ok OK - alert button + alert action +alert button Old database @@ -5373,6 +5418,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -5815,7 +5864,7 @@ swipe action Remove member? メンバーを除名しますか? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -5882,6 +5931,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -5985,6 +6038,14 @@ swipe action Review conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke 取り消す @@ -6037,6 +6098,10 @@ chat item action 保存(連絡先に通知) alert button + + Save admission settings? + alert title + Save and notify contact 保存して、連絡先にに知らせる @@ -6500,6 +6565,10 @@ chat item action システム認証の代わりに設定します。 No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8088,6 +8157,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! メッセージを送信できませんでした! @@ -8374,6 +8447,10 @@ Repeat connection request? 上で選んでください: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call 受けた通話 @@ -8383,6 +8460,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin 管理者 @@ -8402,6 +8483,10 @@ Repeat connection request? 暗号化に同意しています… chat item text + + all + member criteria value + all members feature role @@ -8953,6 +9038,7 @@ pref value オフ enabled status group pref value +member criteria value time to disappear @@ -9000,6 +9086,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -9050,6 +9140,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -9220,6 +9318,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group グループ招待が届きました 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 4008c57ac0..f827435b83 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Accepteer voorwaarden @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Geaccepteerde voorwaarden @@ -1581,11 +1593,23 @@ set passcode view De chat wordt voor je verwijderd - dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Chats No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Controleer uw berichten elke 20 minuten. @@ -2824,7 +2848,7 @@ swipe action Don't show again Niet meer weergeven - No comment provided by engineer. + alert action Done @@ -3142,6 +3166,10 @@ chat item action Fout bij het accepteren van een contactverzoek No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Fout bij het toevoegen van leden @@ -3345,7 +3373,7 @@ chat item action Error removing member Fout bij verwijderen van lid - No comment provided by engineer. + alert title Error reordering lists @@ -4729,6 +4757,10 @@ Dit is jouw link voor groep %@! Lid No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Lid inactief @@ -4764,6 +4796,10 @@ Dit is jouw link voor groep %@! Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Groepsleden kunnen bericht reacties toevoegen. @@ -5184,6 +5220,10 @@ Dit is jouw link voor groep %@! Nieuwe leden rol No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message nieuw bericht @@ -5224,6 +5264,10 @@ Dit is jouw link voor groep %@! Geen chats in lijst %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Geen contacten geselecteerd @@ -5416,7 +5460,8 @@ Dit is jouw link voor groep %@! Ok OK - alert button + alert action +alert button Old database @@ -5827,6 +5872,10 @@ Fout: %@ Probeer meldingen uit en weer in te schakelen. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. Wacht tot de tokenactivering voltooid is. @@ -6313,7 +6362,7 @@ swipe action Remove member? Lid verwijderen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6390,6 +6439,10 @@ swipe action Reden melding? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. Spam melden: alleen groepsmoderators kunnen het zien. @@ -6505,6 +6558,14 @@ swipe action Voorwaarden bekijken No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Intrekken @@ -6561,6 +6622,10 @@ chat item action Bewaar (en informeer contacten) alert button + + Save admission settings? + alert title + Save and notify contact Opslaan en Contact melden @@ -7076,6 +7141,10 @@ chat item action Stel het in in plaats van systeemverificatie. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. Stel de berichtvervaldatum in chats in. @@ -8832,6 +8901,10 @@ Deelnameverzoek herhalen? U kunt de uitnodigingslink opnieuw bekijken in de verbindingsdetails. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Je kunt geen berichten versturen! @@ -9134,6 +9207,10 @@ Verbindingsverzoek herhalen? hier boven, kies dan: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call geaccepteerde oproep @@ -9144,6 +9221,10 @@ Verbindingsverzoek herhalen? geaccepteerde uitnodiging chat list item title + + accepted you + rcv group event chat item + admin Beheerder @@ -9164,6 +9245,10 @@ Verbindingsverzoek herhalen? versleuteling overeenkomen… chat item text + + all + member criteria value + all members alle leden @@ -9736,6 +9821,7 @@ pref value uit enabled status group pref value +member criteria value time to disappear @@ -9788,6 +9874,10 @@ time to disappear in afwachting van goedkeuring No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption quantum bestendige e2e-codering @@ -9843,6 +9933,14 @@ time to disappear verzocht om verbinding te maken chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved opgeslagen @@ -10032,6 +10130,10 @@ laatst ontvangen bericht: %2$@ jij No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group je bent uitgenodigd voor de groep 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 175c8b4112..87c78b1d65 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Zaakceptuj warunki @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Zaakceptowano warunki @@ -1575,11 +1587,23 @@ set passcode view Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Czaty No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Sprawdzaj wiadomości co 20 min. @@ -2787,7 +2811,7 @@ swipe action Don't show again Nie pokazuj ponownie - No comment provided by engineer. + alert action Done @@ -3100,6 +3124,10 @@ chat item action Błąd przyjmowania prośby o kontakt No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Błąd dodawania członka(ów) @@ -3297,7 +3325,7 @@ chat item action Error removing member Błąd usuwania członka - No comment provided by engineer. + alert title Error reordering lists @@ -4641,6 +4669,10 @@ To jest twój link do grupy %@! Członek No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Członek nieaktywny @@ -4673,6 +4705,10 @@ To jest twój link do grupy %@! Członek zostanie usunięty z grupy - nie można tego cofnąć! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Członkowie grupy mogą dodawać reakcje wiadomości. @@ -5083,6 +5119,10 @@ To jest twój link do grupy %@! Nowa rola członka No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Nowa wiadomość @@ -5119,6 +5159,10 @@ To jest twój link do grupy %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Nie wybrano kontaktów @@ -5298,7 +5342,8 @@ To jest twój link do grupy %@! Ok Ok - alert button + alert action +alert button Old database @@ -5698,6 +5743,10 @@ Błąd: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -6173,7 +6222,7 @@ swipe action Remove member? Usunąć członka? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6245,6 +6294,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -6354,6 +6407,14 @@ swipe action Review conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Odwołaj @@ -6410,6 +6471,10 @@ chat item action Zapisz (i powiadom kontakty) alert button + + Save admission settings? + alert title + Save and notify contact Zapisz i powiadom kontakt @@ -6918,6 +6983,10 @@ chat item action Ustaw go zamiast uwierzytelniania systemowego. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8630,6 +8699,10 @@ Powtórzyć prośbę dołączenia? Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Nie możesz wysyłać wiadomości! @@ -8930,6 +9003,10 @@ Powtórzyć prośbę połączenia? powyżej, a następnie wybierz: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call zaakceptowane połączenie @@ -8939,6 +9016,10 @@ Powtórzyć prośbę połączenia? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin administrator @@ -8959,6 +9040,10 @@ Powtórzyć prośbę połączenia? uzgadnianie szyfrowania… chat item text + + all + member criteria value + all members wszyscy członkowie @@ -9529,6 +9614,7 @@ pref value wyłączony enabled status group pref value +member criteria value time to disappear @@ -9579,6 +9665,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption kwantowo odporne szyfrowanie e2e @@ -9632,6 +9722,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved zapisane @@ -9821,6 +9919,10 @@ ostatnia otrzymana wiadomość: %2$@ Ty No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group jesteś zaproszony do grupy 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 419fa75375..58eb585574 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Принять условия @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Принятые условия @@ -1581,11 +1593,23 @@ set passcode view Разговор будет удален для Вас - это действие нельзя отменить! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Чаты No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Проверять сообщения каждые 20 минут. @@ -2822,7 +2846,7 @@ swipe action Don't show again Не показывать - No comment provided by engineer. + alert action Done @@ -3139,6 +3163,10 @@ chat item action Ошибка при принятии запроса на соединение No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) No comment provided by engineer. @@ -3339,7 +3367,7 @@ chat item action Error removing member - No comment provided by engineer. + alert title Error reordering lists @@ -4714,6 +4742,10 @@ This is your link for group %@! Member No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text @@ -4744,6 +4776,10 @@ This is your link for group %@! Member will be removed from group - this cannot be undone! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. No comment provided by engineer. @@ -5153,6 +5189,10 @@ This is your link for group %@! New member role No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Новое сообщение @@ -5193,6 +5233,10 @@ This is your link for group %@! Нет чатов в списке %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Контакты не выбраны @@ -5382,7 +5426,8 @@ This is your link for group %@! Ok Ок - alert button + alert action +alert button Old database @@ -5792,6 +5837,10 @@ Error: %@ Попробуйте выключить и снова включить уведомления. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. Пожалуйста, дождитесь завершения активации токена. @@ -6275,7 +6324,7 @@ swipe action Remove member? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6352,6 +6401,10 @@ swipe action Причина сообщения? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. Пожаловаться на спам: увидят только модераторы группы. @@ -6467,6 +6520,14 @@ swipe action Посмотреть условия No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Отозвать @@ -6523,6 +6584,10 @@ chat item action Сохранить (и уведомить контакты) alert button + + Save admission settings? + alert title + Save and notify contact Сохранить и уведомить контакт @@ -7036,6 +7101,10 @@ chat item action Установите код вместо системной аутентификации. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. Установите срок хранения сообщений в чатах. @@ -8781,6 +8850,10 @@ Repeat join request? Вы можете увидеть ссылку-приглашение снова открыв соединение. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Вы не можете отправлять сообщения! @@ -9081,6 +9154,10 @@ Repeat connection request? наверху, затем выберите: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call принятый звонок @@ -9091,6 +9168,10 @@ Repeat connection request? принятое приглашение chat list item title + + accepted you + rcv group event chat item + admin админ @@ -9111,6 +9192,10 @@ Repeat connection request? шифрование согласовывается… chat item text + + all + member criteria value + all members feature role @@ -9680,6 +9765,7 @@ pref value нет enabled status group pref value +member criteria value time to disappear @@ -9732,6 +9818,10 @@ time to disappear ожидает утверждения No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption квантово-устойчивое e2e шифрование @@ -9787,6 +9877,14 @@ time to disappear запрошено соединение chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved сохранено @@ -9976,6 +10074,10 @@ last received msg: %2$@ Вы No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group Вы приглашены в группу 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 671dd87d7d..481b8271fb 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -520,6 +520,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions No comment provided by engineer. @@ -539,6 +547,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions No comment provided by engineer. @@ -1429,11 +1441,23 @@ set passcode view Chat will be deleted for you - this cannot be undone! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats แชท No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. No comment provided by engineer. @@ -2551,7 +2575,7 @@ swipe action Don't show again ไม่ต้องแสดงอีก - No comment provided by engineer. + alert action Done @@ -2843,6 +2867,10 @@ chat item action เกิดข้อผิดพลาดในการรับคำขอติดต่อ No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) เกิดข้อผิดพลาดในการเพิ่มสมาชิก @@ -3028,7 +3056,7 @@ chat item action Error removing member เกิดข้อผิดพลาดในการลบสมาชิก - No comment provided by engineer. + alert title Error reordering lists @@ -4293,6 +4321,10 @@ This is your link for group %@! สมาชิก No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive item status text @@ -4324,6 +4356,10 @@ This is your link for group %@! สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. สมาชิกกลุ่มสามารถเพิ่มการแสดงปฏิกิริยาต่อข้อความได้ @@ -4697,6 +4733,10 @@ This is your link for group %@! บทบาทของสมาชิกใหม่ No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message ข้อความใหม่ @@ -4733,6 +4773,10 @@ This is your link for group %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected ไม่ได้เลือกผู้ติดต่อ @@ -4902,7 +4946,8 @@ This is your link for group %@! Ok ตกลง - alert button + alert action +alert button Old database @@ -5274,6 +5319,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -5714,7 +5763,7 @@ swipe action Remove member? ลบสมาชิกออก? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -5781,6 +5830,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -5884,6 +5937,14 @@ swipe action Review conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke ถอน @@ -5936,6 +5997,10 @@ chat item action บันทึก (และแจ้งผู้ติดต่อ) alert button + + Save admission settings? + alert title + Save and notify contact บันทึกและแจ้งผู้ติดต่อ @@ -6404,6 +6469,10 @@ chat item action ตั้งแทนการรับรองความถูกต้องของระบบ No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -7986,6 +8055,10 @@ Repeat join request? You can view invitation link again in connection details. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! คุณไม่สามารถส่งข้อความได้! @@ -8270,6 +8343,10 @@ Repeat connection request? ด้านบน จากนั้นเลือก: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call รับสายแล้ว @@ -8279,6 +8356,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin ผู้ดูแลระบบ @@ -8298,6 +8379,10 @@ Repeat connection request? เห็นด้วยกับการ encryption… chat item text + + all + member criteria value + all members feature role @@ -8848,6 +8933,7 @@ pref value ปิด enabled status group pref value +member criteria value time to disappear @@ -8895,6 +8981,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption chat item text @@ -8945,6 +9035,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved No comment provided by engineer. @@ -9115,6 +9213,10 @@ last received msg: %2$@ you No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group คุณได้รับเชิญให้เข้าร่วมกลุ่ม 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 bbee40c2b9..1acf4576a1 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -563,6 +563,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Koşulları kabul et @@ -584,6 +592,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Kabul edilmiş koşullar @@ -1557,11 +1569,23 @@ set passcode view Sohbet senden silinecek - bu geri alınamaz! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Sohbetler No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Her 20 dakikada mesajları kontrol et. @@ -2780,7 +2804,7 @@ swipe action Don't show again Yeniden gösterme - No comment provided by engineer. + alert action Done @@ -3095,6 +3119,10 @@ chat item action Bağlantı isteği kabul edilirken hata oluştu No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Üye(ler) eklenirken hata oluştu @@ -3294,7 +3322,7 @@ chat item action Error removing member Kişiyi silerken sorun oluştu - No comment provided by engineer. + alert title Error reordering lists @@ -4653,6 +4681,10 @@ Bu senin grup için bağlantın %@! Kişi No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Üye inaktif @@ -4686,6 +4718,10 @@ Bu senin grup için bağlantın %@! Üye gruptan çıkarılacaktır - bu geri alınamaz! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Grup üyeleri mesaj tepkileri ekleyebilir. @@ -5096,6 +5132,10 @@ Bu senin grup için bağlantın %@! Yeni üye rolü No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Yeni mesaj @@ -5132,6 +5172,10 @@ Bu senin grup için bağlantın %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Hiçbir kişi seçilmedi @@ -5311,7 +5355,8 @@ Bu senin grup için bağlantın %@! Ok Tamam - alert button + alert action +alert button Old database @@ -5711,6 +5756,10 @@ Hata: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -6186,7 +6235,7 @@ swipe action Remove member? Kişi silinsin mi? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6258,6 +6307,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -6367,6 +6420,14 @@ swipe action Review conditions No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke İptal et @@ -6423,6 +6484,10 @@ chat item action Kaydet (ve kişilere bildir) alert button + + Save admission settings? + alert title + Save and notify contact Kaydet ve kişilere bildir @@ -6931,6 +6996,10 @@ chat item action Sistem kimlik doğrulaması yerine ayarla. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8646,6 +8715,10 @@ Katılma isteği tekrarlansın mı? Bağlantı detaylarından davet bağlantısını yeniden görüntüleyebilirsin. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Mesajlar gönderemezsiniz! @@ -8945,6 +9018,10 @@ Bağlantı isteği tekrarlansın mı? yukarı çıkın, ardından seçin: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call kabul edilen arama @@ -8954,6 +9031,10 @@ Bağlantı isteği tekrarlansın mı? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin yönetici @@ -8974,6 +9055,10 @@ Bağlantı isteği tekrarlansın mı? şifreleme kabul ediliyor… chat item text + + all + member criteria value + all members bütün üyeler @@ -9544,6 +9629,7 @@ pref value kapalı enabled status group pref value +member criteria value time to disappear @@ -9594,6 +9680,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption kuantuma dayanıklı e2e şifreleme @@ -9647,6 +9737,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved kaydedildi @@ -9836,6 +9934,10 @@ son alınan msj: %2$@ sen No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group gruba davet edildiniz 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 c0375e3b02..2c515339ff 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -563,6 +563,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions Прийняти умови @@ -584,6 +592,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions Прийняті умови @@ -1557,11 +1569,23 @@ set passcode view Чат буде видалено для вас - цю дію неможливо скасувати! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats Чати No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. Перевіряйте повідомлення кожні 20 хв. @@ -2781,7 +2805,7 @@ swipe action Don't show again Більше не показувати - No comment provided by engineer. + alert action Done @@ -3096,6 +3120,10 @@ chat item action Помилка при прийнятті запиту на контакт No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) Помилка додавання користувача(ів) @@ -3295,7 +3323,7 @@ chat item action Error removing member Помилка видалення учасника - No comment provided by engineer. + alert title Error reordering lists @@ -4654,6 +4682,10 @@ This is your link for group %@! Учасник No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive Користувач неактивний @@ -4688,6 +4720,10 @@ This is your link for group %@! Учасник буде видалений з групи - це неможливо скасувати! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. Учасники групи можуть додавати реакції на повідомлення. @@ -5102,6 +5138,10 @@ This is your link for group %@! Нова роль учасника No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message Нове повідомлення @@ -5139,6 +5179,10 @@ This is your link for group %@! No chats in list %@ No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected Не вибрано жодного контакту @@ -5325,7 +5369,8 @@ This is your link for group %@! Ok Гаразд - alert button + alert action +alert button Old database @@ -5732,6 +5777,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -6209,7 +6258,7 @@ swipe action Remove member? Видалити учасника? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6281,6 +6330,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -6391,6 +6444,14 @@ swipe action Умови перегляду No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke Відкликати @@ -6447,6 +6508,10 @@ chat item action Зберегти (і повідомити контактам) alert button + + Save admission settings? + alert title + Save and notify contact Зберегти та повідомити контакт @@ -6959,6 +7024,10 @@ chat item action Встановіть його замість аутентифікації системи. No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8704,6 +8773,10 @@ Repeat join request? Ви можете переглянути посилання на запрошення ще раз у деталях підключення. alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! Ви не можете надсилати повідомлення! @@ -9005,6 +9078,10 @@ Repeat connection request? вище, а потім обирайте: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call прийнято виклик @@ -9015,6 +9092,10 @@ Repeat connection request? прийняте запрошення chat list item title + + accepted you + rcv group event chat item + admin адмін @@ -9035,6 +9116,10 @@ Repeat connection request? узгодження шифрування… chat item text + + all + member criteria value + all members всі учасники @@ -9605,6 +9690,7 @@ pref value вимкнено enabled status group pref value +member criteria value time to disappear @@ -9655,6 +9741,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption квантово-стійке шифрування e2e @@ -9709,6 +9799,14 @@ time to disappear запит на підключення chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved збережено @@ -9898,6 +9996,10 @@ last received msg: %2$@ ти No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group вас запрошують до групи 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 d5411f86e3..683abce868 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 @@ -565,6 +565,14 @@ time interval accept incoming call via notification swipe action + + Accept as member + alert action + + + Accept as observer + alert action + Accept conditions 接受条款 @@ -586,6 +594,10 @@ swipe action accept contact request via notification swipe action + + Accept member + alert title + Accepted conditions 已接受的条款 @@ -1582,11 +1594,23 @@ set passcode view 将为你删除聊天 - 此操作无法撤销! No comment provided by engineer. + + Chat with admins + chat toolbar + + + Chat with member + No comment provided by engineer. + Chats 聊天 No comment provided by engineer. + + Chats with members + No comment provided by engineer. + Check messages every 20 min. 每 20 分钟检查消息。 @@ -2824,7 +2848,7 @@ swipe action Don't show again 不再显示 - No comment provided by engineer. + alert action Done @@ -3142,6 +3166,10 @@ chat item action 接受联系人请求错误 No comment provided by engineer. + + Error accepting member + alert title + Error adding member(s) 添加成员错误 @@ -3344,7 +3372,7 @@ chat item action Error removing member 删除成员错误 - No comment provided by engineer. + alert title Error reordering lists @@ -4728,6 +4756,10 @@ This is your link for group %@! 成员 No comment provided by engineer. + + Member admission + No comment provided by engineer. + Member inactive 成员不活跃 @@ -4763,6 +4795,10 @@ This is your link for group %@! 成员将被移出群组——此操作无法撤消! No comment provided by engineer. + + Member will join the group, accept member? + alert message + Members can add message reactions. 群组成员可以添加信息回应。 @@ -5183,6 +5219,10 @@ This is your link for group %@! 新成员角色 No comment provided by engineer. + + New member wants to join the group. + rcv group event chat item + New message 新消息 @@ -5223,6 +5263,10 @@ This is your link for group %@! 列表 %@ 中无聊天 No comment provided by engineer. + + No chats with members + No comment provided by engineer. + No contacts selected 未选择联系人 @@ -5415,7 +5459,8 @@ This is your link for group %@! Ok 好的 - alert button + alert action +alert button Old database @@ -5823,6 +5868,10 @@ Error: %@ Please try to disable and re-enable notfications. token info + + Please wait for group moderators to review your request to join the group. + snd group event chat item + Please wait for token activation to complete. token info @@ -6297,7 +6346,7 @@ swipe action Remove member? 删除成员吗? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6369,6 +6418,10 @@ swipe action Report reason? No comment provided by engineer. + + Report sent to moderators + alert title + Report spam: only group moderators will see it. report reason @@ -6479,6 +6532,14 @@ swipe action 审阅条款 No comment provided by engineer. + + Review members + admission stage + + + Review members before admitting ("knocking"). + admission stage description + Revoke 吊销 @@ -6534,6 +6595,10 @@ chat item action 保存(并通知联系人) alert button + + Save admission settings? + alert title + Save and notify contact 保存并通知联系人 @@ -7041,6 +7106,10 @@ chat item action 设置它以代替系统身份验证。 No comment provided by engineer. + + Set member admission + No comment provided by engineer. + Set message expiration in chats. No comment provided by engineer. @@ -8750,6 +8819,10 @@ Repeat join request? 您可以在连接详情中再次查看邀请链接。 alert message + + You can view your reports in Chat with admins. + alert message + You can't send messages! 您无法发送消息! @@ -9045,6 +9118,10 @@ Repeat connection request? 上面,然后选择: No comment provided by engineer. + + accepted %@ + rcv group event chat item + accepted call 已接受通话 @@ -9054,6 +9131,10 @@ Repeat connection request? accepted invitation chat list item title + + accepted you + rcv group event chat item + admin 管理员 @@ -9074,6 +9155,10 @@ Repeat connection request? 同意加密… chat item text + + all + member criteria value + all members 所有成员 @@ -9644,6 +9729,7 @@ pref value 关闭 enabled status group pref value +member criteria value time to disappear @@ -9694,6 +9780,10 @@ time to disappear pending approval No comment provided by engineer. + + pending review + No comment provided by engineer. + quantum resistant e2e encryption 抗量子端到端加密 @@ -9747,6 +9837,14 @@ time to disappear requested to connect chat list item title + + review + No comment provided by engineer. + + + reviewed by admins + No comment provided by engineer. + saved 已保存 @@ -9936,6 +10034,10 @@ last received msg: %2$@ No comment provided by engineer. + + you accepted this member + snd group event chat item + you are invited to group 您被邀请加入群组 diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index e4bc8f2150..47f1390e0b 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1485,7 +1485,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "Не активирай"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Не показвай отново"; /* No comment provided by engineer. */ @@ -1777,7 +1777,7 @@ chat item action */ /* alert title */ "Error receiving file" = "Грешка при получаване на файл"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Грешка при отстраняване на член"; /* No comment provided by engineer. */ @@ -2747,6 +2747,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "изключено"; @@ -2759,7 +2760,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "предлага %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ок"; /* No comment provided by engineer. */ @@ -3156,7 +3158,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Острани член"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Острани член?"; /* No comment provided by engineer. */ @@ -3776,9 +3778,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Профилът се споделя само с вашите контакти."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Втората отметка, която пропуснахме! ✅"; @@ -4446,10 +4445,10 @@ chat item action */ "Your profile **%@** will be shared." = "Вашият профил **%@** ще бъде споделен."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил."; +"Your profile is stored on your device and only shared with your contacts." = "Профилът се споделя само с вашите контакти."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил."; /* No comment provided by engineer. */ "Your random profile" = "Вашият автоматично генериран профил"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 08a94615a3..cc19af7a74 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1127,7 +1127,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "Nepovolovat"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Znovu neukazuj"; /* No comment provided by engineer. */ @@ -1367,7 +1367,7 @@ swipe action */ /* alert title */ "Error receiving file" = "Chyba při příjmu souboru"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Chyba při odebrání člena"; /* No comment provided by engineer. */ @@ -2145,6 +2145,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "vypnuto"; @@ -2157,7 +2158,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "nabídl %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -2476,7 +2478,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Odstranit člena"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Odebrat člena?"; /* No comment provided by engineer. */ @@ -2988,9 +2990,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stará databáze nebyla během přenášení odstraněna, lze ji smazat."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Profil je sdílen pouze s vašimi kontakty."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Druhé zaškrtnutí jsme přehlédli! ✅"; @@ -3472,10 +3471,10 @@ chat item action */ "Your profile **%@** will be shared." = "Váš profil **%@** bude sdílen."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty. Servery SimpleX nevidí váš profil."; +"Your profile is stored on your device and only shared with your contacts." = "Profil je sdílen pouze s vašimi kontakty."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Váš profil, kontakty a doručené zprávy jsou uloženy ve vašem zařízení."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Váš profil je uložen ve vašem zařízení a sdílen pouze s vašimi kontakty. Servery SimpleX nevidí váš profil."; /* No comment provided by engineer. */ "Your random profile" = "Váš náhodný profil"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 8da7835c43..71cfaa841b 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1872,7 +1872,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "Verpassen Sie keine wichtigen Nachrichten."; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Nicht nochmals anzeigen"; /* No comment provided by engineer. */ @@ -2236,7 +2236,7 @@ chat item action */ /* alert title */ "Error registering for notifications" = "Fehler beim Registrieren für Benachrichtigungen"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Fehler beim Entfernen des Mitglieds"; /* alert title */ @@ -3587,6 +3587,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "Aus"; @@ -3599,7 +3600,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "angeboten %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3695,6 +3697,9 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Gruppe öffnen"; +/* alert title */ +"Open link?" = "Link öffnen?"; + /* authentication reason */ "Open migration to another device" = "Migration auf ein anderes Gerät öffnen"; @@ -4170,7 +4175,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Mitglied entfernen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Das Mitglied entfernen?"; /* No comment provided by engineer. */ @@ -5100,9 +5105,6 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Die alte Datenbank wurde während der Migration nicht entfernt. Sie kann gelöscht werden."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Das Profil wird nur mit Ihren Kontakten geteilt."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dieselben Nutzungsbedingungen gelten auch für den Betreiber **%@**."; @@ -5991,15 +5993,15 @@ report reason */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Ihr Profil **%@** wird geteilt."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ihr Profil wird auf Ihrem Gerät gespeichert und nur mit Ihren Kontakten geteilt. SimpleX-Server können Ihr Profil nicht einsehen."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ihr Profil wurde geändert. Wenn Sie es speichern, wird das aktualisierte Profil an alle Ihre Kontakte gesendet."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Ihr Profil, Ihre Kontakte und zugestellten Nachrichten werden auf Ihrem Gerät gespeichert."; - /* No comment provided by engineer. */ "Your random profile" = "Ihr Zufallsprofil"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 28ba0f0642..9ae294f6ef 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1872,7 +1872,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "No pierdas los mensajes importantes."; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "No volver a mostrar"; /* No comment provided by engineer. */ @@ -2236,7 +2236,7 @@ chat item action */ /* alert title */ "Error registering for notifications" = "Error al registrarse para notificaciones"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Error al expulsar miembro"; /* alert title */ @@ -3587,6 +3587,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "desactivado"; @@ -3599,7 +3600,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "ofrecido %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -4170,7 +4172,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Expulsar miembro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "¿Expulsar miembro?"; /* No comment provided by engineer. */ @@ -5100,9 +5102,6 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "La base de datos antigua no se eliminó durante la migración, puede eliminarse."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Las mismas condiciones se aplicarán al operador **%@**."; @@ -5991,15 +5990,15 @@ report reason */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "El perfil **%@** será compartido."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil es almacenado en tu dispositivo y solamente se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Tu perfil, contactos y mensajes se almacenan en tu dispositivo."; - /* No comment provided by engineer. */ "Your random profile" = "Tu perfil aleatorio"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 4891c7fb26..3aa3bdbc42 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -1073,7 +1073,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "Älä salli"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Älä näytä uudelleen"; /* No comment provided by engineer. */ @@ -1307,7 +1307,7 @@ swipe action */ /* alert title */ "Error receiving file" = "Virhe tiedoston vastaanottamisessa"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Virhe poistettaessa jäsentä"; /* No comment provided by engineer. */ @@ -2079,6 +2079,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "pois"; @@ -2091,7 +2092,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "tarjottu %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -2407,7 +2409,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Poista jäsen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Poista jäsen?"; /* No comment provided by engineer. */ @@ -2910,9 +2912,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Vanhaa tietokantaa ei poistettu siirron aikana, se voidaan kuitenkin poistaa."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Profiili jaetaan vain kontaktiesi kanssa."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Toinen kuittaus, joka uupui! ✅"; @@ -3391,10 +3390,10 @@ chat item action */ "Your profile **%@** will be shared." = "Profiilisi **%@** jaetaan."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. SimpleX-palvelimet eivät näe profiiliasi."; +"Your profile is stored on your device and only shared with your contacts." = "Profiili jaetaan vain kontaktiesi kanssa."; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Profiilisi, kontaktisi ja toimitetut viestit tallennetaan laitteellesi."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiilisi tallennetaan laitteeseesi ja jaetaan vain yhteystietojesi kanssa. SimpleX-palvelimet eivät näe profiiliasi."; /* No comment provided by engineer. */ "Your random profile" = "Satunnainen profiilisi"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 4dd75039dc..55a76aaa37 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1863,7 +1863,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "Ne manquez pas les messages importants."; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Ne plus afficher"; /* No comment provided by engineer. */ @@ -2227,7 +2227,7 @@ chat item action */ /* alert title */ "Error registering for notifications" = "Erreur lors de l'inscription aux notifications"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Erreur lors de la suppression d'un membre"; /* alert title */ @@ -3479,6 +3479,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "off"; @@ -3491,7 +3492,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "propose %1$@ : %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -4014,7 +4016,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Retirer le membre"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Retirer ce membre ?"; /* No comment provided by engineer. */ @@ -4883,9 +4885,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "L'ancienne base de données n'a pas été supprimée lors de la migration, elle peut être supprimée."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Le profil n'est partagé qu'avec vos contacts."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Les mêmes conditions s'appliquent à l'opérateur **%@**."; @@ -5741,15 +5740,15 @@ chat item action */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Votre profil **%@** sera partagé."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Le profil n'est partagé qu'avec vos contacts."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Votre profil est stocké sur votre appareil et est seulement partagé avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Votre profil a été modifié. Si vous l'enregistrez, le profil mis à jour sera envoyé à tous vos contacts."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Votre profil, vos contacts et les messages reçus sont stockés sur votre appareil."; - /* No comment provided by engineer. */ "Your random profile" = "Votre profil aléatoire"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 5a9b6b4e38..5431eb51d8 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1505,7 +1505,7 @@ set passcode view */ "Database ID: %d" = "Adatbázis-azonosító: %d"; /* No comment provided by engineer. */ -"Database IDs and Transport isolation option." = "Adatbázis-azonosítók és átvitel-izolációs beállítások."; +"Database IDs and Transport isolation option." = "Adatbázis-azonosítók és átvitelelkülönítési beállítások."; /* No comment provided by engineer. */ "Database is encrypted using a random passphrase, you can change it." = "Az adatbázis egy véletlenszerű jelmondattal van titkosítva, amelyet szabadon módosíthat."; @@ -1550,7 +1550,7 @@ set passcode view */ "Decentralized" = "Decentralizált"; /* message decrypt error item */ -"Decryption error" = "Titkosítás visszafejtési hiba"; +"Decryption error" = "Titkosításvisszafejtési hiba"; /* No comment provided by engineer. */ "decryption errors" = "visszafejtési hibák"; @@ -1777,7 +1777,7 @@ swipe action */ "different migration in the app/database: %@ / %@" = "különböző átköltöztetés az alkalmazásban/adatbázisban: %@ / %@"; /* No comment provided by engineer. */ -"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitel-izoláció."; +"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitelizoláció."; /* connection level description */ "direct" = "közvetlen"; @@ -1872,7 +1872,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "Ne maradjon le a fontos üzenetekről."; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Ne mutasd újra"; /* No comment provided by engineer. */ @@ -2189,7 +2189,7 @@ chat item action */ "Error deleting token" = "Hiba történt a token törlésekor"; /* No comment provided by engineer. */ -"Error deleting user profile" = "Hiba történt a felhasználó-profil törlésekor"; +"Error deleting user profile" = "Hiba történt a felhasználói profil törlésekor"; /* No comment provided by engineer. */ "Error downloading the archive" = "Hiba történt az archívum letöltésekor"; @@ -2236,7 +2236,7 @@ chat item action */ /* alert title */ "Error registering for notifications" = "Hiba történt az értesítések regisztrálásakor"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Hiba történt a tag eltávolításakor"; /* alert title */ @@ -2956,10 +2956,10 @@ snd error text */ "It allows having many anonymous connections without any shared data between them in a single chat profile." = "Lehetővé teszi, hogy egyetlen csevegési profilon belül több névtelen kapcsolat legyen, anélkül, hogy megosztott adatok lennének közöttük."; /* No comment provided by engineer. */ -"It can happen when you or your connection used the old database backup." = "Ez akkor fordulhat elő, ha Ön vagy a partnere régi adatbázis biztonsági mentést használt."; +"It can happen when you or your connection used the old database backup." = "Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta."; /* No comment provided by engineer. */ -"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült."; +"It can happen when:\n1. The messages expired in the sending client after 2 days or on the server after 30 days.\n2. Message decryption failed, because you or your contact used old database backup.\n3. The connection was compromised." = "Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere egy régi adatbázis biztonsági mentését használta.\n3. A kapcsolat sérült."; /* No comment provided by engineer. */ "It protects your IP address and connections." = "Védi az IP-címét és a kapcsolatait."; @@ -3274,10 +3274,10 @@ snd error text */ "Messages were deleted after you selected them." = "Az üzeneteket törölték miután kijelölte őket."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, sérülés utáni titkosságvédelemmel és -helyreállítással, továbbá letagadhatósággal vannak védve."; +"Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; /* No comment provided by engineer. */ -"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, sérülés utáni titkosságvédelemmel és -helyreállítással, továbbá letagadhatósággal vannak védve."; +"Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti kvantumbiztos titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; /* No comment provided by engineer. */ "Migrate device" = "Eszköz átköltöztetése"; @@ -3550,7 +3550,7 @@ snd error text */ "No unread chats" = "Nincsenek olvasatlan csevegések"; /* No comment provided by engineer. */ -"No user identifiers." = "Nincsenek felhasználó-azonosítók."; +"No user identifiers." = "Nincsenek felhasználói azonosítók."; /* No comment provided by engineer. */ "Not compatible!" = "Nem kompatibilis!"; @@ -3587,6 +3587,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "kikapcsolva"; @@ -3599,7 +3600,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "ajánlotta: %1$@, ekkor: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Rendben"; /* No comment provided by engineer. */ @@ -3695,6 +3697,9 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Csoport megnyitása"; +/* alert title */ +"Open link?" = "Megnyitja a hivatkozást?"; + /* authentication reason */ "Open migration to another device" = "Átköltöztetés indítása egy másik eszközre"; @@ -4153,10 +4158,10 @@ swipe action */ "rejected call" = "elutasított hívás"; /* No comment provided by engineer. */ -"Relay server is only used if necessary. Another party can observe your IP address." = "A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címet."; +"Relay server is only used if necessary. Another party can observe your IP address." = "A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét."; /* No comment provided by engineer. */ -"Relay server protects your IP address, but it can observe the duration of the call." = "A továbbítókiszolgáló megvédi az Ön IP-címét, de megfigyelheti a hívás időtartamát."; +"Relay server protects your IP address, but it can observe the duration of the call." = "A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; /* No comment provided by engineer. */ "Remove" = "Eltávolítás"; @@ -4170,7 +4175,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Eltávolítás"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Eltávolítja a tagot?"; /* No comment provided by engineer. */ @@ -4830,7 +4835,7 @@ chat item action */ "SimpleX one-time invitation" = "Egyszer használható SimpleX-meghívó"; /* No comment provided by engineer. */ -"SimpleX protocols reviewed by Trail of Bits." = "A SimpleX Chat biztonsága a Trail of Bits által lett felülvizsgálva."; +"SimpleX protocols reviewed by Trail of Bits." = "A SimpleX-protokollokat a Trail of Bits auditálta."; /* No comment provided by engineer. */ "Simplified incognito mode" = "Egyszerűsített inkognitómód"; @@ -5100,9 +5105,6 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "A régi adatbázis nem lett eltávolítva az átköltöztetéskor, ezért törölhető."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "A profilja csak a partnereivel van megosztva."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Ugyanezek a feltételek lesznek elfogadva a következő üzemeltető számára is: **%@**."; @@ -5263,7 +5265,7 @@ report reason */ "Total" = "Összes kapcsolat"; /* No comment provided by engineer. */ -"Transport isolation" = "Átvitel-izoláció"; +"Transport isolation" = "Átvitelelkülönítés"; /* No comment provided by engineer. */ "Transport sessions" = "Munkamenetek átvitele"; @@ -5458,7 +5460,7 @@ report reason */ "Use only local notifications?" = "Csak helyi értesítések használata?"; /* No comment provided by engineer. */ -"Use private routing with unknown servers when IP address is not protected." = "Használjon privát útválasztást ismeretlen kiszolgálókkal, ha az IP-cím nem védett."; +"Use private routing with unknown servers when IP address is not protected." = "Használjon privát útválasztást az ismeretlen kiszolgálókkal, ha az IP-cím nem védett."; /* No comment provided by engineer. */ "Use private routing with unknown servers." = "Használjon privát útválasztást ismeretlen kiszolgálókkal."; @@ -5680,10 +5682,10 @@ report reason */ "With reduced battery usage." = "Csökkentett akkumulátor-használattal."; /* No comment provided by engineer. */ -"Without Tor or VPN, your IP address will be visible to file servers." = "Tor vagy VPN nélkül az Ön IP-címe látható lesz a fájlkiszolgálók számára."; +"Without Tor or VPN, your IP address will be visible to file servers." = "Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára."; /* alert message */ -"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az Ön IP-címe látható lesz a következő XFTP-továbbítókiszolgálók számára: %@."; +"Without Tor or VPN, your IP address will be visible to these XFTP relays: %@." = "Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára: %@."; /* No comment provided by engineer. */ "Wrong database passphrase" = "Érvénytelen adatbázis-jelmondat"; @@ -5991,15 +5993,15 @@ report reason */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "A(z) **%@** nevű profilja meg lesz osztva."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "A profilja az eszközén van tárolva és csak a partnereivel van megosztva."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX-kiszolgálók nem láthatják a profilját."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "A profilja módosult. Ha elmenti, a profilfrissítés el lesz küldve a partnerei számára."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "A profilja, a partnerei és az elküldött üzenetei a saját eszközén vannak tárolva."; - /* No comment provided by engineer. */ "Your random profile" = "Véletlenszerű profil"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index b914a06079..996484aea0 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1872,7 +1872,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "Non perdere messaggi importanti."; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Non mostrare più"; /* No comment provided by engineer. */ @@ -2236,7 +2236,7 @@ chat item action */ /* alert title */ "Error registering for notifications" = "Errore di registrazione per le notifiche"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Errore nella rimozione del membro"; /* alert title */ @@ -3587,6 +3587,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "off"; @@ -3599,7 +3600,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "offerto %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3695,6 +3697,9 @@ time to disappear */ /* No comment provided by engineer. */ "Open group" = "Apri gruppo"; +/* alert title */ +"Open link?" = "Aprire il link?"; + /* authentication reason */ "Open migration to another device" = "Apri migrazione ad un altro dispositivo"; @@ -4170,7 +4175,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Rimuovi membro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Rimuovere il membro?"; /* No comment provided by engineer. */ @@ -5100,9 +5105,6 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Il database vecchio non è stato rimosso durante la migrazione, può essere eliminato."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Il profilo è condiviso solo con i tuoi contatti."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Le stesse condizioni si applicheranno all'operatore **%@**."; @@ -5991,15 +5993,15 @@ report reason */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Verrà condiviso il tuo profilo **%@**."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Il profilo è condiviso solo con i tuoi contatti."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Il tuo profilo è memorizzato sul tuo dispositivo e condiviso solo con i tuoi contatti. I server di SimpleX non possono vedere il tuo profilo."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Il tuo profilo è stato cambiato. Se lo salvi, il profilo aggiornato verrà inviato a tutti i tuoi contatti."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Il tuo profilo, i contatti e i messaggi recapitati sono memorizzati sul tuo dispositivo."; - /* No comment provided by engineer. */ "Your random profile" = "Il tuo profilo casuale"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index d214f88e1c..8e4b071f88 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -1277,7 +1277,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "有効にしない"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "次から表示しない"; /* No comment provided by engineer. */ @@ -1514,7 +1514,7 @@ swipe action */ /* alert title */ "Error receiving file" = "ファイル受信にエラー発生"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "メンバー除名にエラー発生"; /* No comment provided by engineer. */ @@ -2295,6 +2295,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "オフ"; @@ -2307,7 +2308,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "提供された %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "OK"; /* No comment provided by engineer. */ @@ -2626,7 +2628,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "メンバーを除名する"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "メンバーを除名しますか?"; /* No comment provided by engineer. */ @@ -3111,9 +3113,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "古いデータベースは移行時に削除されなかったので、削除することができます。"; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "長らくお待たせしました! ✅"; @@ -3592,10 +3591,10 @@ chat item action */ "Your profile **%@** will be shared." = "あなたのプロファイル **%@** が共有されます。"; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。 SimpleX サーバーはあなたのプロファイルを参照できません。"; +"Your profile is stored on your device and only shared with your contacts." = "プロフィールは連絡先にしか共有されません。"; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "あなたのプロフィール、連絡先、送信したメッセージがご自分の端末に保存されます。"; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "プロフィールはデバイスに保存され、連絡先とのみ共有されます。 SimpleX サーバーはあなたのプロファイルを参照できません。"; /* No comment provided by engineer. */ "Your random profile" = "あなたのランダム・プロフィール"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 232de56641..f0ca51fffb 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1869,7 +1869,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "Mis geen belangrijke berichten."; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Niet meer weergeven"; /* No comment provided by engineer. */ @@ -2233,7 +2233,7 @@ chat item action */ /* alert title */ "Error registering for notifications" = "Fout bij registreren voor meldingen"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Fout bij verwijderen van lid"; /* alert title */ @@ -3584,6 +3584,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "uit"; @@ -3596,7 +3597,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "voorgesteld %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "OK"; /* No comment provided by engineer. */ @@ -4167,7 +4169,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Lid verwijderen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Lid verwijderen?"; /* No comment provided by engineer. */ @@ -5091,9 +5093,6 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "De oude database is niet verwijderd tijdens de migratie, deze kan worden verwijderd."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Het profiel wordt alleen gedeeld met uw contacten."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Dezelfde voorwaarden gelden voor operator **%@**."; @@ -5970,15 +5969,15 @@ report reason */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Uw profiel **%@** wordt gedeeld."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Het profiel wordt alleen gedeeld met uw contacten."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Uw profiel wordt op uw apparaat opgeslagen en alleen gedeeld met uw contacten. SimpleX servers kunnen uw profiel niet zien."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Je profiel is gewijzigd. Als je het opslaat, wordt het bijgewerkte profiel naar al je contacten verzonden."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Uw profiel, contacten en afgeleverde berichten worden op uw apparaat opgeslagen."; - /* No comment provided by engineer. */ "Your random profile" = "Je willekeurige profiel"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 31a9b87662..e3582c7647 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1761,7 +1761,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "Nie włączaj"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Nie pokazuj ponownie"; /* No comment provided by engineer. */ @@ -2092,7 +2092,7 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "Błąd ponownego łączenia serwerów"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Błąd usuwania członka"; /* No comment provided by engineer. */ @@ -3236,6 +3236,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "wyłączony"; @@ -3248,7 +3249,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "zaoferował %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ok"; /* No comment provided by engineer. */ @@ -3744,7 +3746,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Usuń członka"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Usunąć członka?"; /* No comment provided by engineer. */ @@ -4556,9 +4558,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stara baza danych nie została usunięta podczas migracji, można ją usunąć."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Profil jest udostępniany tylko Twoim kontaktom."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Drugi tik, który przegapiliśmy! ✅"; @@ -5354,15 +5353,15 @@ chat item action */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Twój profil **%@** zostanie udostępniony."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Profil jest udostępniany tylko Twoim kontaktom."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Twój profil jest przechowywany na urządzeniu i udostępniany tylko Twoim kontaktom. Serwery SimpleX nie mogą zobaczyć Twojego profilu."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Twój profil został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do wszystkich kontaktów."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Twój profil, kontakty i dostarczone wiadomości są przechowywane na Twoim urządzeniu."; - /* No comment provided by engineer. */ "Your random profile" = "Twój losowy profil"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index cb837836ff..c14c7a7e9f 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1860,7 +1860,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "Не пропустите важные сообщения."; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Не показывать"; /* No comment provided by engineer. */ @@ -3482,6 +3482,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "нет"; @@ -3494,7 +3495,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "предложил(a) %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Ок"; /* No comment provided by engineer. */ @@ -4956,9 +4958,6 @@ report reason */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Предыдущая версия данных чата не удалена при перемещении, её можно удалить."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Ваш профиль храниться на Вашем устройстве и отправляется только контактам."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Те же самые условия будут приняты для оператора **%@**."; @@ -5811,15 +5810,15 @@ report reason */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Будет отправлен Ваш профиль **%@**."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Ваш профиль храниться на Вашем устройстве и отправляется только контактам."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ваш профиль был изменен. Если вы сохраните его, обновленный профиль будет отправлен всем вашим контактам."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве."; - /* No comment provided by engineer. */ "Your random profile" = "Случайный профиль"; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 57c0466eb9..d6e48caf86 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -1028,7 +1028,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "อย่าเปิดใช้งาน"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "ไม่ต้องแสดงอีก"; /* No comment provided by engineer. */ @@ -1256,7 +1256,7 @@ swipe action */ /* alert title */ "Error receiving file" = "เกิดข้อผิดพลาดในการรับไฟล์"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "เกิดข้อผิดพลาดในการลบสมาชิก"; /* No comment provided by engineer. */ @@ -2016,6 +2016,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "ปิด"; @@ -2028,7 +2029,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "เสนอแล้ว %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "ตกลง"; /* No comment provided by engineer. */ @@ -2338,7 +2340,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "ลบสมาชิกออก"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "ลบสมาชิกออก?"; /* No comment provided by engineer. */ @@ -2829,9 +2831,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "ฐานข้อมูลเก่าไม่ได้ถูกลบในระหว่างการย้ายข้อมูล แต่สามารถลบได้"; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น"; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "ขีดที่สองที่เราพลาด! ✅"; @@ -3292,10 +3291,10 @@ chat item action */ "Your privacy" = "ความเป็นส่วนตัวของคุณ"; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "โปรไฟล์ของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณและแชร์กับผู้ติดต่อของคุณเท่านั้น เซิร์ฟเวอร์ SimpleX ไม่สามารถดูโปรไฟล์ของคุณได้"; +"Your profile is stored on your device and only shared with your contacts." = "โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น"; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "โปรไฟล์ รายชื่อผู้ติดต่อ และข้อความที่ส่งของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณ"; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "โปรไฟล์ของคุณจะถูกจัดเก็บไว้ในอุปกรณ์ของคุณและแชร์กับผู้ติดต่อของคุณเท่านั้น เซิร์ฟเวอร์ SimpleX ไม่สามารถดูโปรไฟล์ของคุณได้"; /* No comment provided by engineer. */ "Your random profile" = "โปรไฟล์แบบสุ่มของคุณ"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index e3bb11d1cc..2433c9ae58 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1740,7 +1740,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "Etkinleştirme"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Yeniden gösterme"; /* No comment provided by engineer. */ @@ -2083,7 +2083,7 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "Hata sunuculara yeniden bağlanılıyor"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Kişiyi silerken sorun oluştu"; /* No comment provided by engineer. */ @@ -3272,6 +3272,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "kapalı"; @@ -3284,7 +3285,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "%1$@: %2$@ teklif etti"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Tamam"; /* No comment provided by engineer. */ @@ -3780,7 +3782,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Kişiyi sil"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Kişi silinsin mi?"; /* No comment provided by engineer. */ @@ -4601,9 +4603,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Eski veritabanı geçiş sırasında kaldırılmadı, silinebilir."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Profil sadece kişilerinle paylaşılacak."; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Özlediğimiz ikinci tik! ✅"; @@ -5399,15 +5398,15 @@ chat item action */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Profiliniz **%@** paylaşılacaktır."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Profil sadece kişilerinle paylaşılacak."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Profiliniz cihazınızda saklanır ve sadece kişilerinizle paylaşılır. SimpleX sunucuları profilinizi göremez."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Profiliniz değiştirildi. Kaydederseniz, güncellenmiş profil tüm kişilerinize gönderilecektir."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Profiliniz, kişileriniz ve gönderilmiş mesajlar cihazınızda saklanır."; - /* No comment provided by engineer. */ "Your random profile" = "Rasgele profiliniz"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 734b8dda82..6e6197cdb7 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1746,7 +1746,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't enable" = "Не вмикати"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "Більше не показувати"; /* No comment provided by engineer. */ @@ -2089,7 +2089,7 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "Помилка перепідключення серверів"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "Помилка видалення учасника"; /* No comment provided by engineer. */ @@ -3317,6 +3317,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "вимкнено"; @@ -3329,7 +3330,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "запропонував %1$@: %2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "Гаразд"; /* No comment provided by engineer. */ @@ -3852,7 +3854,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Видалити учасника"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Видалити учасника?"; /* No comment provided by engineer. */ @@ -4721,9 +4723,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Стара база даних не була видалена під час міграції, її можна видалити."; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "Профіль доступний лише вашим контактам."; - /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Такі ж умови діятимуть і для оператора **%@**."; @@ -5579,15 +5578,15 @@ chat item action */ /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Ваш профіль **%@** буде опублікований."; +/* No comment provided by engineer. */ +"Your profile is stored on your device and only shared with your contacts." = "Профіль доступний лише вашим контактам."; + /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Ваш профіль зберігається на вашому пристрої і доступний лише вашим контактам. Сервери SimpleX не бачать ваш профіль."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Ваш профіль було змінено. Якщо ви збережете його, оновлений профіль буде надіслано всім вашим контактам."; -/* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої."; - /* No comment provided by engineer. */ "Your random profile" = "Ваш випадковий профіль"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index e3f9669d9f..9df0e04717 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1866,7 +1866,7 @@ swipe action */ /* No comment provided by engineer. */ "Don't miss important messages." = "不错过重要消息。"; -/* No comment provided by engineer. */ +/* alert action */ "Don't show again" = "不再显示"; /* No comment provided by engineer. */ @@ -2227,7 +2227,7 @@ chat item action */ /* alert title */ "Error registering for notifications" = "注册消息推送出错"; -/* No comment provided by engineer. */ +/* alert title */ "Error removing member" = "删除成员错误"; /* alert title */ @@ -3575,6 +3575,7 @@ snd error text */ /* enabled status group pref value +member criteria value time to disappear */ "off" = "关闭"; @@ -3587,7 +3588,8 @@ time to disappear */ /* feature offered item */ "offered %@: %@" = "已提供 %1$@:%2$@"; -/* alert button */ +/* alert action +alert button */ "Ok" = "好的"; /* No comment provided by engineer. */ @@ -4107,7 +4109,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "删除成员"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "删除成员吗?"; /* No comment provided by engineer. */ @@ -4922,9 +4924,6 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "旧数据库在迁移过程中没有被移除,可以删除。"; -/* No comment provided by engineer. */ -"Your profile is stored on your device and only shared with your contacts." = "该资料仅与您的联系人共享。"; - /* No comment provided by engineer. */ "The second tick we missed! ✅" = "我们错过的第二个\"√\"!✅"; @@ -5697,10 +5696,10 @@ chat item action */ "Your profile **%@** will be shared." = "您的个人资料 **%@** 将被共享。"; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。"; +"Your profile is stored on your device and only shared with your contacts." = "该资料仅与您的联系人共享。"; /* No comment provided by engineer. */ -"Your profile, contacts and delivered messages are stored on your device." = "您的资料、联系人和发送的消息存储在您的设备上。"; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。"; /* No comment provided by engineer. */ "Your random profile" = "您的随机资料"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 6646720c5c..5529c8dcb3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1465,7 +1465,7 @@ Profil erstellen %s und %s Ihrer Gruppe beitreten? - %1$s.]]> + %1$s.]]> Das ist Ihr eigener Einmal-Link! %d Nachrichten als gelöscht markiert Gruppe besteht bereits! @@ -2462,4 +2462,37 @@ Aus TCP-Port 443 nur für voreingestellte Server verwenden. Voreingestellte Server + %d Chats mit Mitgliedern + %d Chat(s) + Meldung wurde an die Moderatoren gesendet + Sie haben dieses Mitglied übernommen + Überprüfung der Mitglieder vor der Aufnahme (Anklopfen). + Überprüfung der Mitglieder + alle + Aus + Als Beobachter übernehmen + Mitglied übernehmen + Chats mit Mitgliedern + Chat mit Administratoren + Keine Chats mit Mitgliedern + Entfernen + hat Sie übernommen + Chat mit einem Mitglied + %d Nachrichten + Ein Mitglied wird der Gruppe beitreten. Übernehmen? + Ein neues Mitglied will der Gruppe beitreten. + Überprüfung + Von Administratoren überprüft + Aufnahme von Mitgliedern festlegen + Speichern der Aufnahme-Einstellungen? + Sie können Ihre Meldungen im Chat mit den Administratoren sehen. + Chat mit Administratoren + %1$s übernommen + Als Mitglied übernehmen + Fehler beim Übernehmen eines Mitglieds + Aufnahme von Mitgliedern + Ausstehende Überprüfung + Chat mit einem Mitglied + Übernehmen + Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index c8897c4063..e3d668d4f0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -350,7 +350,7 @@ Az adatbázis titkosítási jelmondata frissülni fog és a beállításokban lesz tárolva. Adatbázis-azonosító Adatbázis-azonosító: %d - Adatbázis-azonosítók és átvitel-izolációs beállítások. + Adatbázis-azonosítók és átvitelelkülönítési beállítások. Az adatbázis titkosítási jelmondata frissülni fog és a Keystore-ban lesz tárolva. Az adatbázis titkosítva lesz, a jelmondat pedig a beállításokban lesz tárolva. Kiszolgáló törlése @@ -366,7 +366,7 @@ %dmp Kézbesítési jelentések! Az eszközön nincs beállítva a képernyőzár. A SimpleX-zár az „Adatvédelem és biztonság” menüben kapcsolható be, miután beállította a képernyőzárat az eszközén. - Titkosítás visszafejtési hiba + Titkosításvisszafejtési hiba Eltűnik: %s szerkesztve Törlés @@ -532,7 +532,7 @@ Fájlok és médiatartalmak KONZOLHOZ Nem sikerült a titkosítást újraegyeztetni. - Hiba történt a felhasználó-profil törlésekor + Hiba történt a felhasználóprofil törlésekor Csoporttag általi javítás nem támogatott Adja meg az üdvözlőüzenetet… Titkosított adatbázis @@ -676,7 +676,7 @@ Csevegési profil létrehozása Védett a kéretlen tartalommal szemben Hordozható eszközök leválasztása - Különböző nevek, profilképek és átvitel-izoláció. + Különböző nevek, profilképek és átvitelelkülönítés. Elutasítás esetén a feladó NEM kap értesítést. Szerepkörválasztó kibontása A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! @@ -716,7 +716,7 @@ Hamarosan további fejlesztések érkeznek! A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben. Helytelen biztonsági kód! - Ez akkor fordulhat elő, ha Ön vagy a partnere régi adatbázis biztonsági mentést használt. + Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta. Új számítógép-alkalmazás! Most már az adminisztrátorok is:\n- törölhetik a tagok üzeneteit.\n- letilthatnak tagokat (megfigyelő szerepkör) meghívta őt: %1$s @@ -810,7 +810,7 @@ %s ajánlotta Csoport elhagyása %s összes üzenete meg fog jelenni! - Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Nem sikerült visszafejteni az üzenetet, mert Ön, vagy a partnere régebbi adatbázis biztonsági mentést használt.\n3. A kapcsolat sérült. + Ez akkor fordulhat elő, ha:\n1. Az üzenetek 2 nap után, vagy a kiszolgálón 30 nap után lejártak.\n2. Nem sikerült az üzenetet visszafejteni, mert Ön, vagy a partnere egy régi adatbázis biztonsági mentését használta.\n3. A kapcsolat sérült. megfigyelő inkognitó a csoporthivatkozáson keresztül Onion-kiszolgálók használata, ha azok rendelkezésre állnak. @@ -1203,7 +1203,7 @@ Ön eltávolította őt: %1$s Jelmondat mentése és a csevegés megnyitása Menti a beállításokat? - Nincsenek felhasználó-azonosítók. + Nincsenek felhasználói azonosítók. A közvetlen üzenetek küldése a tagok között le van tiltva. SOCKS-proxy használata? Hangszóró kikapcsolva @@ -1345,7 +1345,7 @@ %1$s nevű csoporthoz.]]> Amikor az alkalmazás fut Inkognitóprofilt használ ehhez a csoporthoz – fő profilja megosztásának elkerülése érdekében a meghívók küldése le van tiltva - Átvitel-izoláció + Átvitelelkülönítés Akkor lesz kapcsolódva, ha a meghívási kérése el lesz fogadva, várjon, vagy ellenőrizze később! A hangüzenetek küldése le van tiltva. Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> @@ -1364,8 +1364,8 @@ Adatainak védelme érdekében a SimpleX külön üzenet-azonosítókat használ minden egyes kapcsolatához. (a megosztáshoz a partnerével) Csoportmeghívó elküldve - Frissíti az átvitel-izoláció módját? - Átvitel-izoláció + Frissíti az átvitelelkülönítési módot? + Átvitelelkülönítés Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. Közvetlen internetkapcsolat használata? @@ -1395,7 +1395,7 @@ a SimpleX Chat fejlesztőivel, ahol bármiről kérdezhet és értesülhet a friss hírekről.]]> Nem kötelező üdvözlőüzenettel. Ismeretlen adatbázishiba: %s - Elrejtheti vagy lenémíthatja a felhasználó-profiljait – koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. + Elrejtheti vagy lenémíthatja a felhasználóprofiljait – koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. Inkognitóra váltás kapcsolódáskor. Megoszthat egy hivatkozást vagy QR-kódot – így bárki csatlakozhat a csoporthoz. Ha a csoportot Ön később törli, akkor nem fogja elveszíteni annak tagjait. Ön csatlakozott ehhez a csoporthoz @@ -1435,7 +1435,7 @@ A kézbesítési jelentések le vannak tiltva %d csoportban Néhány nem végzetes hiba történt az importáláskor: Köszönet a felhasználóknak a Weblate-en való közreműködésért! - A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címet. + A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. Beállítás a rendszer-hitelesítés helyett. A fogadási cím egy másik kiszolgálóra fog módosulni. A cím módosítása a feladó online állapotba kerülése után fejeződik be. A csevegés megállítása a csevegési adatbázis exportálásához, importálásához vagy törléséhez. A csevegés megállításakor nem tud üzeneteket fogadni és küldeni. @@ -1444,7 +1444,7 @@ Jelmondat mentése a beállításokban Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem lesznek elküldve. A második jelölés, amit kihagytunk! ✅ - A továbbítókiszolgáló megvédi az Ön IP-címét, de megfigyelheti a hívás időtartamát. + A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. A mentett WebRTC ICE-kiszolgálók el lesznek távolítva. A kézbesítési jelentések engedélyezve vannak %d csoportban @@ -1664,8 +1664,8 @@ Ez a csevegés végpontok közötti titkosítással védett. Átköltöztetési párbeszédablak megnyitása Ez a csevegés végpontok közötti kvantumbiztos titkosítással védett. - végpontok közötti titkosítással, sérülés utáni titkosságvédelemmel és -helyreállítással, továbbá letagadhatósággal vannak védve.]]> - végpontok közötti kvantumbiztos titkosítással, sérülés utáni titkosságvédelemmel és -helyreállítással, továbbá letagadhatósággal vannak védve.]]> + végpontok közötti titkosítással, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve.]]> + végpontok közötti kvantumbiztos titkosítással, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve.]]> Hiba történt az értesítés megjelenítésekor, lépjen kapcsolatba a fejlesztőkkel. Keresse meg ezt az engedélyt az Android beállításaiban, és adja meg kézzel. Engedélyezés a beállításokban @@ -1749,14 +1749,14 @@ Az IP-cím védelmének érdekében a privát útválasztás az SMP-kiszolgálókat használja az üzenetek kézbesítéséhez. Üzenet-útválasztási tartalék PRIVÁT ÜZENET-ÚTVÁLASZTÁS - Használjon privát útválasztást ismeretlen kiszolgálókkal, ha az IP-cím nem védett. + Használjon privát útválasztást az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. - Tor vagy VPN nélkül az Ön IP-címe látható lesz a fájlkiszolgálók számára. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára. FÁJLOK IP-cím védelme Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS-proxy engedélyezve van). Ismeretlen kiszolgálók! - Tor vagy VPN nélkül az Ön IP-címe látható lesz a következő XFTP-továbbítókiszolgálók számára:\n%1$s. + Tor vagy VPN nélkül az IP-címe láthatóvá válik a következő XFTP-továbbítókiszolgálók számára:\n%1$s. Összes színmód Fekete Színmód @@ -2072,7 +2072,7 @@ Hang/Videó váltása hívás közben. Csevegési profilváltás az egyszer használható meghívókhoz. Továbbfejlesztett biztonság ✅ - A SimpleX Chat biztonsága a Trail of Bits által lett felülvizsgálva. + A SimpleX-protokollokat a Trail of Bits auditálta. Hiba történt a kiszolgálók mentésekor Nincsenek üzenet-kiszolgálók. Nincsenek üzenetfogadási kiszolgálók. @@ -2354,4 +2354,37 @@ Kikapcsolva Előre beállított kiszolgálók A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. + Hiba a tag befogadásakor + %d csevegés a tagokkal + %d üzenet + 1 csevegés egy taggal + %d csevegés + A jelentés el lett küldve a moderátoroknak + A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben. + áttekintésre vár + áttekintés + Csevegés az adminisztrátorokkal + Csevegés a tagokkal + Tagbefogadás + Nincsenek csevegések a tagokkal + Tagok áttekintése + Tagok áttekintése a befogadás előtt (kopogtatás). + Csevegés az adminisztrátorokkal + A tag csatlakozni akar a csoporthoz, befogadja a tagot? + Eltávolítás + Befogadás + Tag befogadása + összes + Csevegés a taggal + Új tag szeretne csatlakozni a csoporthoz. + kikapcsolva + Befogadás megfigyelőként + Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérelmét. + befogadta Önt + Tagbefogadás beállítása + Elmenti a befogadási beállításokat? + Ön befogadta ezt a tagot + Befogadás tagként + befogadta őt: %1$s + áttekintve a moderátorok által diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index a269149e99..b69ed8405f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -434,7 +434,7 @@ Panggilan berlangsung Menghubungkan panggilan Pesan yang terlewati - Hash dari pesan sebelumnya berbeda.\" + Hash dari pesan sebelumnya berbeda. Privasi & keamanan Enkripsi berkas lokal Terima gambar otomatis diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 6c086835ea..bbd8c9404f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2391,4 +2391,37 @@ Off Server preimpostati Usa la porta TCP 443 solo per i server preimpostati. + 1 chat con un membro + %d chat + %d chat con membri + %d messaggi + Salvare le impostazioni di ammissione? + ha accettato %1$s + ti ha accettato/a + Attendi che i moderatori del gruppo revisionino la tua richiesta di entrare nel gruppo. + hai accettato questo membro + revisiona + Ammissione del membro + Nessuna chat con membri + off + Revisiona i membri + Revisiona i membri prima di ammetterli (bussare). + Accetta + Chat con amministratori + Rimuovi + Il membro entrerà nel gruppo, accettarlo? + revisionato dagli amministratori + Accetta membro + Chatta con gli amministratori + Accetta come osservatore + Il nuovo membro vuole entrare nel gruppo. + tutti + Chatta con il membro + Chat con membri + Errore di accettazione del membro + Accetta come membro + Imposta l\'ammissione del membro + Segnalazione inviata ai moderatori + in attesa di revisione + Puoi vedere i tuoi resoconti nella chat con gli amministratori. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 549cb01b63..a962298f19 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -459,7 +459,7 @@ Створити без зашифрування e2e контакт має зашифрування e2e - Хеш попереднього повідомлення інший.\" + Хеш попереднього повідомлення інший. Підтвердити пароль Новий пароль Перезапустити diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 28a5f6f50d..e68b8bcdaa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -2375,4 +2375,37 @@ 关闭 预设服务器 仅预设服务器使用 TCP 协议 443 端口。 + 接受成员出错 + 举报已发送至 moderators + %d 个聊天 + 和成员的 %d 个聊天 + %d 条消息 + 接受了 %1$s + 接受了你 + 你接受了该成员 + 新成员要加入本群。 + 审核 + 待审核 + 全部 + 成员准入 + 关闭 + 删除 + 接受 + 和成员聊天 + 和管理员聊天 + 没有和成员的聊天 + 接受为成员 + 接受成员 + 成员将加入本群,接受成员吗? + 由管理员审核 + 设置成员入群准许 + 和成员聊天 + 和管理员聊天 + 准许入群前审核成员(knocking)。 + 请等待群的 moderator 审核你加入该群的请求。 + 审核成员 + 保存入群设置? + 你可以在和管理员和聊天中查看你的举报。 + 接受为观察员 + 和一名成员的一个聊天 From 22364a28be5118db1503bb5bfa1fb75a3e579b8e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 20 May 2025 14:11:49 +0100 Subject: [PATCH 54/72] ios: export localizations --- .../Views/Chat/Group/MemberSupportView.swift | 2 +- .../bg.xcloc/Localized Contents/bg.xliff | 50 ++++++++++++++- .../cs.xcloc/Localized Contents/cs.xliff | 50 ++++++++++++++- .../de.xcloc/Localized Contents/de.xliff | 50 ++++++++++++++- .../en.xcloc/Localized Contents/en.xliff | 62 ++++++++++++++++++- .../es.xcloc/Localized Contents/es.xliff | 50 ++++++++++++++- .../fi.xcloc/Localized Contents/fi.xliff | 50 ++++++++++++++- .../fr.xcloc/Localized Contents/fr.xliff | 50 ++++++++++++++- .../hu.xcloc/Localized Contents/hu.xliff | 50 ++++++++++++++- .../it.xcloc/Localized Contents/it.xliff | 50 ++++++++++++++- .../ja.xcloc/Localized Contents/ja.xliff | 50 ++++++++++++++- .../nl.xcloc/Localized Contents/nl.xliff | 50 ++++++++++++++- .../pl.xcloc/Localized Contents/pl.xliff | 50 ++++++++++++++- .../ru.xcloc/Localized Contents/ru.xliff | 50 ++++++++++++++- .../th.xcloc/Localized Contents/th.xliff | 50 ++++++++++++++- .../tr.xcloc/Localized Contents/tr.xliff | 50 ++++++++++++++- .../uk.xcloc/Localized Contents/uk.xliff | 50 ++++++++++++++- .../Localized Contents/zh-Hans.xliff | 50 ++++++++++++++- .../commonMain/resources/MR/base/strings.xml | 2 +- 19 files changed, 847 insertions(+), 19 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 0bcf09aabd..7f3672ea17 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -271,7 +271,7 @@ func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) { logger.error("apiDeleteMemberSupportChat error: \(responseError(error))") await MainActor.run { showAlert( - NSLocalizedString("Error deleting member support chat", comment: "alert title"), + NSLocalizedString("Error deleting chat with member", comment: "alert title"), message: responseError(error) ) } 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 7f38695470..b5a217d8c0 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -2324,6 +2324,10 @@ swipe action Изтриване на чат профила? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -3122,6 +3126,10 @@ chat item action Грешка при изтриване на базата данни No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Грешка при изтриването на чата! @@ -5999,6 +6007,10 @@ swipe action Отхвърли заявката за контакт No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Реле сървър се използва само ако е необходимо. Друга страна може да наблюдава вашия IP адрес. @@ -6030,7 +6042,7 @@ swipe action Remove member? Острани член? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -8832,6 +8844,10 @@ marked deleted chat item preview text повикване… call status + + can't send messages + No comment provided by engineer. + cancelled %@ отменен %@ @@ -8937,6 +8953,14 @@ marked deleted chat item preview text името на контакта %1$@ е променено на %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption контактът има e2e криптиране @@ -8947,6 +8971,10 @@ marked deleted chat item preview text контактът няма e2e криптиране No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator създател @@ -9115,6 +9143,10 @@ pref value групата е изтрита No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated профилът на групата е актуализиран @@ -9238,6 +9270,10 @@ pref value свързан rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -9301,6 +9337,10 @@ pref value няма текст copied message info in history + + not synchronized + No comment provided by engineer. + observer наблюдател @@ -9403,6 +9443,10 @@ time to disappear премахнат адрес за контакт profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture премахната профилна снимка @@ -9413,6 +9457,10 @@ time to disappear ви острани rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 23709c29de..fe0e02ccdf 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2217,6 +2217,10 @@ swipe action Smazat chat profil? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2993,6 +2997,10 @@ chat item action Chyba při mazání databáze chatu No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Chyba při mazání chatu! @@ -5780,6 +5788,10 @@ swipe action Odmítnout žádost o kontakt No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Přenosový server se používá pouze v případě potřeby. Jiná strana může sledovat vaši IP adresu. @@ -5811,7 +5823,7 @@ swipe action Remove member? Odebrat člena? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -8520,6 +8532,10 @@ marked deleted chat item preview text volání… call status + + can't send messages + No comment provided by engineer. + cancelled %@ zrušeno %@ @@ -8624,6 +8640,14 @@ marked deleted chat item preview text contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kontakt má šifrování e2e @@ -8634,6 +8658,10 @@ marked deleted chat item preview text kontakt nemá šifrování e2e No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator tvůrce @@ -8800,6 +8828,10 @@ pref value skupina smazána No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated profil skupiny aktualizován @@ -8922,6 +8954,10 @@ pref value připojeno rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8985,6 +9021,10 @@ pref value žádný text copied message info in history + + not synchronized + No comment provided by engineer. + observer pozorovatel @@ -9084,6 +9124,10 @@ time to disappear removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -9093,6 +9137,10 @@ time to disappear odstranil vás rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 f4e5083fb0..94d7c0b81b 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -2426,6 +2426,10 @@ swipe action Chat-Profil löschen? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Chat löschen? @@ -3266,6 +3270,10 @@ chat item action Fehler beim Löschen der Chat-Datenbank No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Fehler beim Löschen des Chats! @@ -6331,6 +6339,10 @@ swipe action Kontaktanfrage ablehnen No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relais-Server werden nur genutzt, wenn sie benötigt werden. Ihre IP-Adresse kann von Anderen erfasst werden. @@ -6364,7 +6376,7 @@ swipe action Remove member? Das Mitglied entfernen? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9343,6 +9355,10 @@ marked deleted chat item preview text Anrufen… call status + + can't send messages + No comment provided by engineer. + cancelled %@ abgebrochen %@ @@ -9448,6 +9464,14 @@ marked deleted chat item preview text Der Kontaktname wurde von %1$@ auf %2$@ geändert profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption Kontakt nutzt E2E-Verschlüsselung @@ -9458,6 +9482,10 @@ marked deleted chat item preview text Kontakt nutzt keine E2E-Verschlüsselung No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator Ersteller @@ -9629,6 +9657,10 @@ pref value Gruppe gelöscht No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated Gruppenprofil aktualisiert @@ -9754,6 +9786,10 @@ pref value ist der Gruppe beigetreten rcv group event chat item + + member has old version + No comment provided by engineer. + message Nachricht @@ -9819,6 +9855,10 @@ pref value Kein Text copied message info in history + + not synchronized + No comment provided by engineer. + observer Beobachter @@ -9926,6 +9966,10 @@ time to disappear Die Kontaktadresse wurde entfernt profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture Das Profil-Bild wurde entfernt @@ -9936,6 +9980,10 @@ time to disappear hat Sie aus der Gruppe entfernt rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect Zur Verbindung aufgefordert 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 9821e49098..5982f620b8 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2432,6 +2432,11 @@ swipe action Delete chat profile? No comment provided by engineer. + + Delete chat with member? + Delete chat with member? + alert title + Delete chat? Delete chat? @@ -3273,6 +3278,11 @@ chat item action Error deleting chat database No comment provided by engineer. + + Error deleting chat with member + Error deleting chat with member + alert title + Error deleting chat! Error deleting chat! @@ -6343,6 +6353,11 @@ swipe action Reject contact request No comment provided by engineer. + + Reject member? + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relay server is only used if necessary. Another party can observe your IP address. @@ -6376,7 +6391,7 @@ swipe action Remove member? Remove member? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9364,6 +9379,11 @@ marked deleted chat item preview text calling… call status + + can't send messages + can't send messages + No comment provided by engineer. + cancelled %@ cancelled %@ @@ -9469,6 +9489,16 @@ marked deleted chat item preview text contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + contact deleted + No comment provided by engineer. + + + contact disabled + contact disabled + No comment provided by engineer. + contact has e2e encryption contact has e2e encryption @@ -9479,6 +9509,11 @@ marked deleted chat item preview text contact has no e2e encryption No comment provided by engineer. + + contact not ready + contact not ready + No comment provided by engineer. + creator creator @@ -9650,6 +9685,11 @@ pref value group deleted No comment provided by engineer. + + group is deleted + group is deleted + No comment provided by engineer. + group profile updated group profile updated @@ -9775,6 +9815,11 @@ pref value connected rcv group event chat item + + member has old version + member has old version + No comment provided by engineer. + message message @@ -9840,6 +9885,11 @@ pref value no text copied message info in history + + not synchronized + not synchronized + No comment provided by engineer. + observer observer @@ -9948,6 +9998,11 @@ time to disappear removed contact address profile update event chat item + + removed from group + removed from group + No comment provided by engineer. + removed profile picture removed profile picture @@ -9958,6 +10013,11 @@ time to disappear removed you rcv group event chat item + + request to join rejected + request to join rejected + No comment provided by engineer. + requested to connect requested to connect 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 2a9d2e9430..3c3ae9ff46 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -2426,6 +2426,10 @@ swipe action ¿Eliminar perfil? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? ¿Eliminar chat? @@ -3266,6 +3270,10 @@ chat item action Error al eliminar base de datos No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! ¡Error al eliminar chat! @@ -6330,6 +6338,10 @@ swipe action Rechazar solicitud de contacto No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. El servidor de retransmisión sólo se usa en caso de necesidad. Un tercero podría ver tu IP. @@ -6363,7 +6375,7 @@ swipe action Remove member? ¿Expulsar miembro? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9342,6 +9354,10 @@ marked deleted chat item preview text llamando… call status + + can't send messages + No comment provided by engineer. + cancelled %@ cancelado %@ @@ -9447,6 +9463,14 @@ marked deleted chat item preview text el contacto %1$@ ha cambiado a %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption el contacto dispone de cifrado de extremo a extremo @@ -9457,6 +9481,10 @@ marked deleted chat item preview text el contacto no dispone de cifrado de extremo a extremo No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator creador @@ -9628,6 +9656,10 @@ pref value grupo eliminado No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated perfil de grupo actualizado @@ -9753,6 +9785,10 @@ pref value conectado rcv group event chat item + + member has old version + No comment provided by engineer. + message mensaje @@ -9818,6 +9854,10 @@ pref value sin texto copied message info in history + + not synchronized + No comment provided by engineer. + observer observador @@ -9925,6 +9965,10 @@ time to disappear dirección de contacto eliminada profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture ha eliminado la imagen del perfil @@ -9935,6 +9979,10 @@ time to disappear te ha expulsado rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect solicitado para conectar 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 6e5928281d..7c93c5b0bb 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2198,6 +2198,10 @@ swipe action Poista keskusteluprofiili? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2972,6 +2976,10 @@ chat item action Virhe keskustelujen tietokannan poistamisessa No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Virhe keskutelun poistamisessa! @@ -5756,6 +5764,10 @@ swipe action Hylkää yhteyspyyntö No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Välityspalvelinta käytetään vain tarvittaessa. Toinen osapuoli voi tarkkailla IP-osoitettasi. @@ -5787,7 +5799,7 @@ swipe action Remove member? Poista jäsen? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -8493,6 +8505,10 @@ marked deleted chat item preview text soittaa… call status + + can't send messages + No comment provided by engineer. + cancelled %@ peruutettu %@ @@ -8596,6 +8612,14 @@ marked deleted chat item preview text contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kontaktilla on e2e-salaus @@ -8606,6 +8630,10 @@ marked deleted chat item preview text kontaktilla ei ole e2e-salausta No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator luoja @@ -8772,6 +8800,10 @@ pref value ryhmä poistettu No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated ryhmäprofiili päivitetty @@ -8894,6 +8926,10 @@ pref value yhdistetty rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8957,6 +8993,10 @@ pref value ei tekstiä copied message info in history + + not synchronized + No comment provided by engineer. + observer tarkkailija @@ -9056,6 +9096,10 @@ time to disappear removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -9065,6 +9109,10 @@ time to disappear poisti sinut rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 edafb47a53..80b3428cfe 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -2424,6 +2424,10 @@ swipe action Supprimer le profil du chat ? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Supprimer la discussion ? @@ -3264,6 +3268,10 @@ chat item action Erreur lors de la suppression de la base de données du chat No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Erreur lors de la suppression du chat ! @@ -6283,6 +6291,10 @@ swipe action Rejeter la demande de contact No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Le serveur relais n'est utilisé que si nécessaire. Un tiers peut observer votre adresse IP. @@ -6316,7 +6328,7 @@ swipe action Remove member? Retirer ce membre ? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9263,6 +9275,10 @@ marked deleted chat item preview text appel… call status + + can't send messages + No comment provided by engineer. + cancelled %@ annulé %@ @@ -9368,6 +9384,14 @@ marked deleted chat item preview text le contact %1$@ est devenu %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption Ce contact a le chiffrement de bout en bout @@ -9378,6 +9402,10 @@ marked deleted chat item preview text Ce contact n'a pas le chiffrement de bout en bout No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator créateur @@ -9549,6 +9577,10 @@ pref value groupe supprimé No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated mise à jour du profil de groupe @@ -9674,6 +9706,10 @@ pref value est connecté·e rcv group event chat item + + member has old version + No comment provided by engineer. + message message @@ -9738,6 +9774,10 @@ pref value aucun texte copied message info in history + + not synchronized + No comment provided by engineer. + observer observateur @@ -9842,6 +9882,10 @@ time to disappear suppression de l'adresse de contact profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture suppression de la photo de profil @@ -9852,6 +9896,10 @@ time to disappear vous a retiré rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect demande à se connecter 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 559875b5cd..43f7d8b31e 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -2426,6 +2426,10 @@ swipe action Törli a csevegési profilt? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Törli a csevegést? @@ -3266,6 +3270,10 @@ chat item action Hiba történt a csevegési adatbázis törlésekor No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Hiba történt a csevegés törlésekor! @@ -6331,6 +6339,10 @@ swipe action Meghívási kérés elutasítása No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. A továbbítókiszolgáló csak szükség esetén lesz használva. Egy másik fél megfigyelheti az IP-címét. @@ -6364,7 +6376,7 @@ swipe action Remove member? Eltávolítja a tagot? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9343,6 +9355,10 @@ marked deleted chat item preview text hívás… call status + + can't send messages + No comment provided by engineer. + cancelled %@ %@ visszavonva @@ -9448,6 +9464,14 @@ marked deleted chat item preview text %1$@ a következőre módosította a nevét: %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption a partner e2e titkosítással rendelkezik @@ -9458,6 +9482,10 @@ marked deleted chat item preview text a partner nem rendelkezik e2e titkosítással No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator készítő @@ -9629,6 +9657,10 @@ pref value a csoport törölve No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated csoportprofil frissítve @@ -9754,6 +9786,10 @@ pref value kapcsolódott rcv group event chat item + + member has old version + No comment provided by engineer. + message üzenet @@ -9819,6 +9855,10 @@ pref value nincs szöveg copied message info in history + + not synchronized + No comment provided by engineer. + observer megfigyelő @@ -9926,6 +9966,10 @@ time to disappear eltávolította a kapcsolattartási címet profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture eltávolította a profilképét @@ -9936,6 +9980,10 @@ time to disappear eltávolította Önt rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect Függőben lévő meghívási kérelem 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 e3f29b9ce5..d672a0da4f 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -2426,6 +2426,10 @@ swipe action Eliminare il profilo di chat? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Eliminare la chat? @@ -3266,6 +3270,10 @@ chat item action Errore nell'eliminazione del database della chat No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Errore nell'eliminazione della chat! @@ -6331,6 +6339,10 @@ swipe action Rifiuta la richiesta di contatto No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Il server relay viene usato solo se necessario. Un altro utente può osservare il tuo indirizzo IP. @@ -6364,7 +6376,7 @@ swipe action Remove member? Rimuovere il membro? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9343,6 +9355,10 @@ marked deleted chat item preview text chiamata… call status + + can't send messages + No comment provided by engineer. + cancelled %@ annullato %@ @@ -9448,6 +9464,14 @@ marked deleted chat item preview text contatto %1$@ cambiato in %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption il contatto ha la crittografia e2e @@ -9458,6 +9482,10 @@ marked deleted chat item preview text il contatto non ha la crittografia e2e No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator creatore @@ -9629,6 +9657,10 @@ pref value gruppo eliminato No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated profilo del gruppo aggiornato @@ -9754,6 +9786,10 @@ pref value si è connesso/a rcv group event chat item + + member has old version + No comment provided by engineer. + message messaggio @@ -9819,6 +9855,10 @@ pref value nessun testo copied message info in history + + not synchronized + No comment provided by engineer. + observer osservatore @@ -9926,6 +9966,10 @@ time to disappear indirizzo di contatto rimosso profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture immagine del profilo rimossa @@ -9936,6 +9980,10 @@ time to disappear ti ha rimosso/a rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect richiesto di connettersi 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 ab4dd08133..2a7bfa8df1 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2268,6 +2268,10 @@ swipe action チャットのプロフィールを削除しますか? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -3046,6 +3050,10 @@ chat item action チャットデータベース削除にエラー発生 No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! チャット削除にエラー発生! @@ -5833,6 +5841,10 @@ swipe action 連絡要求を拒否する No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. 中継サーバーは必要な場合にのみ使用されます。 別の当事者があなたの IP アドレスを監視できます。 @@ -5864,7 +5876,7 @@ swipe action Remove member? メンバーを除名しますか? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -8564,6 +8576,10 @@ marked deleted chat item preview text 発信中… call status + + can't send messages + No comment provided by engineer. + cancelled %@ キャンセルされました %@ @@ -8667,6 +8683,14 @@ marked deleted chat item preview text contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption 連絡先はエンドツーエンド暗号化があります @@ -8677,6 +8701,10 @@ marked deleted chat item preview text 連絡先はエンドツーエンド暗号化がありません No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator 作成者 @@ -8843,6 +8871,10 @@ pref value グループ削除済み No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated グループのプロフィールが更新されました @@ -8965,6 +8997,10 @@ pref value 接続中 rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -9028,6 +9064,10 @@ pref value テキストなし copied message info in history + + not synchronized + No comment provided by engineer. + observer オブザーバー @@ -9127,6 +9167,10 @@ time to disappear removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -9136,6 +9180,10 @@ time to disappear あなたを除名しました rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 f827435b83..d0b430cf02 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -2425,6 +2425,10 @@ swipe action Chatprofiel verwijderen? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Chat verwijderen? @@ -3265,6 +3269,10 @@ chat item action Fout bij het verwijderen van de chat database No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Fout bij verwijderen gesprek! @@ -6329,6 +6337,10 @@ swipe action Contactverzoek afwijzen No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relay server wordt alleen gebruikt als dat nodig is. Een andere partij kan uw IP-adres zien. @@ -6362,7 +6374,7 @@ swipe action Remove member? Lid verwijderen? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9335,6 +9347,10 @@ marked deleted chat item preview text bellen… call status + + can't send messages + No comment provided by engineer. + cancelled %@ geannuleerd %@ @@ -9440,6 +9456,14 @@ marked deleted chat item preview text contactpersoon %1$@ gewijzigd in %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption contact heeft e2e-codering @@ -9450,6 +9474,10 @@ marked deleted chat item preview text contact heeft geen e2e versleuteling No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator creator @@ -9621,6 +9649,10 @@ pref value groep verwijderd No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated groep profiel bijgewerkt @@ -9746,6 +9778,10 @@ pref value is toegetreden rcv group event chat item + + member has old version + No comment provided by engineer. + message bericht @@ -9811,6 +9847,10 @@ pref value geen tekst copied message info in history + + not synchronized + No comment provided by engineer. + observer Waarnemer @@ -9918,6 +9958,10 @@ time to disappear contactadres verwijderd profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profielfoto verwijderd @@ -9928,6 +9972,10 @@ time to disappear heeft je verwijderd rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect verzocht om verbinding te maken 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 87c78b1d65..3255489efd 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -2398,6 +2398,10 @@ swipe action Usunąć profil czatu? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -3219,6 +3223,10 @@ chat item action Błąd usuwania bazy danych czatu No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Błąd usuwania czatu! @@ -6189,6 +6197,10 @@ swipe action Odrzuć prośbę kontaktu No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Serwer przekaźnikowy jest używany tylko w razie potrzeby. Inna strona może obserwować Twój adres IP. @@ -6222,7 +6234,7 @@ swipe action Remove member? Usunąć członka? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9129,6 +9141,10 @@ marked deleted chat item preview text dzwonie… call status + + can't send messages + No comment provided by engineer. + cancelled %@ anulowany %@ @@ -9234,6 +9250,14 @@ marked deleted chat item preview text kontakt %1$@ zmieniony na %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kontakt posiada szyfrowanie e2e @@ -9244,6 +9268,10 @@ marked deleted chat item preview text kontakt nie posiada szyfrowania e2e No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator twórca @@ -9415,6 +9443,10 @@ pref value grupa usunięta No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated zaktualizowano profil grupy @@ -9540,6 +9572,10 @@ pref value połączony rcv group event chat item + + member has old version + No comment provided by engineer. + message wiadomość @@ -9604,6 +9640,10 @@ pref value brak tekstu copied message info in history + + not synchronized + No comment provided by engineer. + observer obserwator @@ -9708,6 +9748,10 @@ time to disappear usunięto adres kontaktu profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture usunięto zdjęcie profilu @@ -9718,6 +9762,10 @@ time to disappear usunął cię rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 58eb585574..651d9a7063 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2425,6 +2425,10 @@ swipe action Удалить профиль? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Удалить разговор? @@ -3260,6 +3264,10 @@ chat item action Ошибка при удалении данных чата No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Ошибка при удалении чата! @@ -6293,6 +6301,10 @@ swipe action Отклонить запрос No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Relay сервер используется только при необходимости. Другая сторона может видеть Ваш IP адрес. @@ -6324,7 +6336,7 @@ swipe action Remove member? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9281,6 +9293,10 @@ marked deleted chat item preview text входящий звонок… call status + + can't send messages + No comment provided by engineer. + cancelled %@ отменил(a) %@ @@ -9386,6 +9402,14 @@ marked deleted chat item preview text контакт %1$@ изменён на %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption у контакта есть e2e шифрование @@ -9396,6 +9420,10 @@ marked deleted chat item preview text у контакта нет e2e шифрования No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator создатель @@ -9567,6 +9595,10 @@ pref value группа удалена No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated профиль группы обновлен @@ -9690,6 +9722,10 @@ pref value соединен(а) rcv group event chat item + + member has old version + No comment provided by engineer. + message написать @@ -9755,6 +9791,10 @@ pref value нет текста copied message info in history + + not synchronized + No comment provided by engineer. + observer читатель @@ -9862,6 +9902,10 @@ time to disappear удалён адрес контакта profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture удалена картинка профиля @@ -9872,6 +9916,10 @@ time to disappear удалил(а) Вас из группы rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect запрошено соединение 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 481b8271fb..528219b13a 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2186,6 +2186,10 @@ swipe action ลบโปรไฟล์แชทไหม? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? No comment provided by engineer. @@ -2956,6 +2960,10 @@ chat item action เกิดข้อผิดพลาดในการลบฐานข้อมูลแชท No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! เกิดข้อผิดพลาดในการลบแชท! @@ -5732,6 +5740,10 @@ swipe action ปฏิเสธคำขอติดต่อ No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. ใช้เซิร์ฟเวอร์รีเลย์ในกรณีที่จำเป็นเท่านั้น บุคคลอื่นสามารถสังเกตที่อยู่ IP ของคุณได้ @@ -5763,7 +5775,7 @@ swipe action Remove member? ลบสมาชิกออก? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -8460,6 +8472,10 @@ marked deleted chat item preview text กำลังโทร… call status + + can't send messages + No comment provided by engineer. + cancelled %@ ยกเลิก %@ @@ -8563,6 +8579,14 @@ marked deleted chat item preview text contact %1$@ changed to %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption ผู้ติดต่อมีการ encrypt จากต้นจนจบ @@ -8573,6 +8597,10 @@ marked deleted chat item preview text ผู้ติดต่อไม่มีการ encrypt จากต้นจนจบ No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator ผู้สร้าง @@ -8738,6 +8766,10 @@ pref value ลบกลุ่มแล้ว No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated อัปเดตโปรไฟล์กลุ่มแล้ว @@ -8860,6 +8892,10 @@ pref value เชื่อมต่อสำเร็จ rcv group event chat item + + member has old version + No comment provided by engineer. + message No comment provided by engineer. @@ -8923,6 +8959,10 @@ pref value ไม่มีข้อความ copied message info in history + + not synchronized + No comment provided by engineer. + observer ผู้สังเกตการณ์ @@ -9022,6 +9062,10 @@ time to disappear removed contact address profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profile update event chat item @@ -9031,6 +9075,10 @@ time to disappear ลบคุณออกแล้ว rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 1acf4576a1..d17a272016 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -2388,6 +2388,10 @@ swipe action Sohbet profili silinsin mi? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Sohbet silinsin mi? @@ -3215,6 +3219,10 @@ chat item action Sohbet veritabanı silinirken sorun oluştu No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Sohbet silinirken hata oluştu! @@ -6202,6 +6210,10 @@ swipe action Bağlanma isteğini reddet No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Yönlendirici sunucusu yalnızca gerekli olduğunda kullanılır. Başka bir taraf IP adresinizi gözlemleyebilir. @@ -6235,7 +6247,7 @@ swipe action Remove member? Kişi silinsin mi? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9144,6 +9156,10 @@ marked deleted chat item preview text aranıyor… call status + + can't send messages + No comment provided by engineer. + cancelled %@ %@ iptal edildi @@ -9249,6 +9265,14 @@ marked deleted chat item preview text %1$@ kişisi %2$@ olarak değişti profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption kişi uçtan uca şifrelemeye sahiptir @@ -9259,6 +9283,10 @@ marked deleted chat item preview text kişi uçtan uca şifrelemeye sahip değildir No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator oluşturan @@ -9430,6 +9458,10 @@ pref value grup silindi No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated grup profili güncellendi @@ -9555,6 +9587,10 @@ pref value bağlanıldı rcv group event chat item + + member has old version + No comment provided by engineer. + message mesaj @@ -9619,6 +9655,10 @@ pref value metin yok copied message info in history + + not synchronized + No comment provided by engineer. + observer gözlemci @@ -9723,6 +9763,10 @@ time to disappear kişi adresi silindi profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture profil fotoğrafı silindi @@ -9733,6 +9777,10 @@ time to disappear sen kaldırıldın rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 2c515339ff..687393cfab 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -2388,6 +2388,10 @@ swipe action Видалити профіль чату? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? Видалити чат? @@ -3216,6 +3220,10 @@ chat item action Помилка видалення бази даних чату No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! Помилка видалення чату! @@ -6225,6 +6233,10 @@ swipe action Відхилити запит на контакт No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. Релейний сервер використовується тільки в разі потреби. Інша сторона може бачити вашу IP-адресу. @@ -6258,7 +6270,7 @@ swipe action Remove member? Видалити учасника? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9205,6 +9217,10 @@ marked deleted chat item preview text дзвоніть… call status + + can't send messages + No comment provided by engineer. + cancelled %@ скасовано %@ @@ -9310,6 +9326,14 @@ marked deleted chat item preview text контакт %1$@ змінено на %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption контакт має шифрування e2e @@ -9320,6 +9344,10 @@ marked deleted chat item preview text контакт не має шифрування e2e No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator творець @@ -9491,6 +9519,10 @@ pref value групу видалено No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated оновлено профіль групи @@ -9616,6 +9648,10 @@ pref value з'єднаний rcv group event chat item + + member has old version + No comment provided by engineer. + message повідомлення @@ -9680,6 +9716,10 @@ pref value без тексту copied message info in history + + not synchronized + No comment provided by engineer. + observer спостерігач @@ -9784,6 +9824,10 @@ time to disappear видалено контактну адресу profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture видалено зображення профілю @@ -9794,6 +9838,10 @@ time to disappear прибрали вас rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect запит на підключення 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 683abce868..06ce8d4950 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 @@ -2425,6 +2425,10 @@ swipe action 删除聊天资料? No comment provided by engineer. + + Delete chat with member? + alert title + Delete chat? 删除聊天? @@ -3264,6 +3268,10 @@ chat item action 删除聊天数据库错误 No comment provided by engineer. + + Error deleting chat with member + alert title + Error deleting chat! 删除聊天错误! @@ -6314,6 +6322,10 @@ swipe action 拒绝联系人请求 No comment provided by engineer. + + Reject member? + alert title + Relay server is only used if necessary. Another party can observe your IP address. 中继服务器仅在必要时使用。其他人可能会观察到您的IP地址。 @@ -6346,7 +6358,7 @@ swipe action Remove member? 删除成员吗? - alert title + No comment provided by engineer. Remove passphrase from keychain? @@ -9244,6 +9256,10 @@ marked deleted chat item preview text 呼叫中…… call status + + can't send messages + No comment provided by engineer. + cancelled %@ 已取消 %@ @@ -9349,6 +9365,14 @@ marked deleted chat item preview text 联系人 %1$@ 已更改为 %2$@ profile update event chat item + + contact deleted + No comment provided by engineer. + + + contact disabled + No comment provided by engineer. + contact has e2e encryption 联系人具有端到端加密 @@ -9359,6 +9383,10 @@ marked deleted chat item preview text 联系人没有端到端加密 No comment provided by engineer. + + contact not ready + No comment provided by engineer. + creator 创建者 @@ -9530,6 +9558,10 @@ pref value 群组已删除 No comment provided by engineer. + + group is deleted + No comment provided by engineer. + group profile updated 群组资料已更新 @@ -9655,6 +9687,10 @@ pref value 已连接 rcv group event chat item + + member has old version + No comment provided by engineer. + message 消息 @@ -9719,6 +9755,10 @@ pref value 无文本 copied message info in history + + not synchronized + No comment provided by engineer. + observer 观察者 @@ -9823,6 +9863,10 @@ time to disappear 删除了联系地址 profile update event chat item + + removed from group + No comment provided by engineer. + removed profile picture 删除了资料图片 @@ -9833,6 +9877,10 @@ time to disappear 已将您移除 rcv group event chat item + + request to join rejected + No comment provided by engineer. + requested to connect chat list item title 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 7bb6562a3f..b5bf2efaff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -159,7 +159,7 @@ Error adding member(s) Error joining group Error accepting member - Error deleting member support chat + Error deleting chat with member Cannot receive file Sender cancelled file transfer. Unknown servers! From a8e7db0b8f3e99a9a6eddf4f0099a54f3b93de30 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 20 May 2025 15:27:03 +0100 Subject: [PATCH 55/72] android, desktop: update translation key --- .../common/src/commonMain/resources/MR/de/strings.xml | 2 +- .../common/src/commonMain/resources/MR/hu/strings.xml | 2 +- .../common/src/commonMain/resources/MR/it/strings.xml | 2 +- .../common/src/commonMain/resources/MR/zh-rCN/strings.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 5529c8dcb3..fbb9f94a64 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -2475,7 +2475,7 @@ Chats mit Mitgliedern Chat mit Administratoren Keine Chats mit Mitgliedern - Entfernen + Entfernen hat Sie übernommen Chat mit einem Mitglied %d Nachrichten diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index e3d668d4f0..dfa4c81504 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -2371,7 +2371,7 @@ Tagok áttekintése a befogadás előtt (kopogtatás). Csevegés az adminisztrátorokkal A tag csatlakozni akar a csoporthoz, befogadja a tagot? - Eltávolítás + Eltávolítás Befogadás Tag befogadása összes diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index bbd8c9404f..bf920e2fca 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2408,7 +2408,7 @@ Revisiona i membri prima di ammetterli (bussare). Accetta Chat con amministratori - Rimuovi + Rimuovi Il membro entrerà nel gruppo, accettarlo? revisionato dagli amministratori Accetta membro diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index e68b8bcdaa..a72a4938c2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -2389,7 +2389,7 @@ 全部 成员准入 关闭 - 删除 + 删除 接受 和成员聊天 和管理员聊天 From 1ef53d153088bf8fc0e9a80200fa08b3b614349a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 20 May 2025 15:38:00 +0100 Subject: [PATCH 56/72] ui: translations (#5931) * Update translation files Updated by "Cleanup translation files" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2404 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2404 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 99.0% (2071 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 99.0% (2071 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 99.9% (2089 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 99.9% (2089 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2404 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2090 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * ios: import --------- Co-authored-by: Hosted Weblate Co-authored-by: summoner001 Co-authored-by: Ghost of Sparta --- .../hu.xcloc/Localized Contents/hu.xliff | 37 ++++++ apps/ios/hu.lproj/Localizable.strings | 113 +++++++++++++++++- .../commonMain/resources/MR/hu/strings.xml | 20 +++- 3 files changed, 167 insertions(+), 3 deletions(-) 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 43f7d8b31e..5fd4c21027 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -567,10 +567,12 @@ swipe action Accept as member + Befogadás tagként alert action Accept as observer + Befogadás megfigyelőként alert action @@ -596,6 +598,7 @@ swipe action Accept member + Tag befogadása alert title @@ -1596,10 +1599,12 @@ set passcode view Chat with admins + Csevegés az adminisztrátorokkal chat toolbar Chat with member + Csevegés a taggal No comment provided by engineer. @@ -1609,6 +1614,7 @@ set passcode view Chats with members + Csevegés a tagokkal No comment provided by engineer. @@ -2428,6 +2434,7 @@ swipe action Delete chat with member? + Törli a taggal való csevegést? alert title @@ -3173,6 +3180,7 @@ chat item action Error accepting member + Hiba a tag befogadásakor alert title @@ -3272,6 +3280,7 @@ chat item action Error deleting chat with member + Hiba a taggal való csevegés törlésekor alert title @@ -4768,6 +4777,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Member admission + Tagbefogadás No comment provided by engineer. @@ -4807,6 +4817,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Member will join the group, accept member? + A tag csatlakozni akar a csoporthoz, befogadja a tagot? alert message @@ -5231,6 +5242,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! New member wants to join the group. + Új tag szeretne csatlakozni a csoporthoz. rcv group event chat item @@ -5275,6 +5287,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No chats with members + Nincsenek csevegések a tagokkal No comment provided by engineer. @@ -5884,6 +5897,7 @@ Hiba: %@ Please wait for group moderators to review your request to join the group. + Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérelmét. snd group event chat item @@ -6341,6 +6355,7 @@ swipe action Reject member? + Elutasítja a tagot? alert title @@ -6455,6 +6470,7 @@ swipe action Report sent to moderators + A jelentés el lett küldve a moderátoroknak alert title @@ -6574,10 +6590,12 @@ swipe action Review members + Tagok áttekintése admission stage Review members before admitting ("knocking"). + Tagok áttekintése a befogadás előtt (kopogtatás). admission stage description @@ -6638,6 +6656,7 @@ chat item action Save admission settings? + Elmenti a befogadási beállításokat? alert title @@ -7157,6 +7176,7 @@ chat item action Set member admission + Tagbefogadás beállítása No comment provided by engineer. @@ -8923,6 +8943,7 @@ Megismétli a meghívási kérést? You can view your reports in Chat with admins. + A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben. alert message @@ -9229,6 +9250,7 @@ Megismétli a meghívási kérést? accepted %@ + befogadta őt: %@ rcv group event chat item @@ -9243,6 +9265,7 @@ Megismétli a meghívási kérést? accepted you + befogadta Önt rcv group event chat item @@ -9267,6 +9290,7 @@ Megismétli a meghívási kérést? all + összes member criteria value @@ -9357,6 +9381,7 @@ marked deleted chat item preview text can't send messages + nem lehet üzeneteket küldeni No comment provided by engineer. @@ -9466,10 +9491,12 @@ marked deleted chat item preview text contact deleted + partner törölve No comment provided by engineer. contact disabled + partner letiltva No comment provided by engineer. @@ -9484,6 +9511,7 @@ marked deleted chat item preview text contact not ready + a kapcsolat nem áll készen No comment provided by engineer. @@ -9659,6 +9687,7 @@ pref value group is deleted + csoport törölve No comment provided by engineer. @@ -9788,6 +9817,7 @@ pref value member has old version + a tag régi verziót használ No comment provided by engineer. @@ -9857,6 +9887,7 @@ pref value not synchronized + nincs szinkronizálva No comment provided by engineer. @@ -9924,6 +9955,7 @@ time to disappear pending review + függőben lévő áttekintés No comment provided by engineer. @@ -9968,6 +10000,7 @@ time to disappear removed from group + eltávolítva a csoportból No comment provided by engineer. @@ -9982,6 +10015,7 @@ time to disappear request to join rejected + csatlakozási kérelem elutasítva No comment provided by engineer. @@ -9991,10 +10025,12 @@ time to disappear review + áttekintés No comment provided by engineer. reviewed by admins + áttekintve a moderátorok által No comment provided by engineer. @@ -10188,6 +10224,7 @@ utoljára fogadott üzenet: %2$@ you accepted this member + Ön befogadta ezt a tagot snd group event chat item diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 5431eb51d8..9a1da01665 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -345,6 +345,12 @@ accept incoming call via notification swipe action */ "Accept" = "Elfogadás"; +/* alert action */ +"Accept as member" = "Befogadás tagként"; + +/* alert action */ +"Accept as observer" = "Befogadás megfigyelőként"; + /* No comment provided by engineer. */ "Accept conditions" = "Feltételek elfogadása"; @@ -358,6 +364,12 @@ swipe action */ swipe action */ "Accept incognito" = "Elfogadás inkognitóban"; +/* alert title */ +"Accept member" = "Tag befogadása"; + +/* rcv group event chat item */ +"accepted %@" = "befogadta őt: %@"; + /* call status */ "accepted call" = "fogadott hívás"; @@ -367,6 +379,9 @@ swipe action */ /* chat list item title */ "accepted invitation" = "elfogadott meghívó"; +/* rcv group event chat item */ +"accepted you" = "befogadta Önt"; + /* No comment provided by engineer. */ "Acknowledged" = "Visszaigazolt"; @@ -463,6 +478,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "titkosítás elfogadása…"; +/* member criteria value */ +"all" = "összes"; + /* No comment provided by engineer. */ "All" = "Összes"; @@ -905,6 +923,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Nem lehet üzenetet küldeni a tagnak"; +/* No comment provided by engineer. */ +"can't send messages" = "nem lehet üzeneteket küldeni"; + /* alert action alert button */ "Cancel" = "Mégse"; @@ -1042,9 +1063,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "A csevegés törölve lesz az Ön számára – ez a művelet nem vonható vissza!"; +/* chat toolbar */ +"Chat with admins" = "Csevegés az adminisztrátorokkal"; + +/* No comment provided by engineer. */ +"Chat with member" = "Csevegés a taggal"; + /* No comment provided by engineer. */ "Chats" = "Csevegések"; +/* No comment provided by engineer. */ +"Chats with members" = "Csevegés a tagokkal"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Üzenetek ellenőrzése 20 percenként."; @@ -1333,9 +1363,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "A partner már létezik"; +/* No comment provided by engineer. */ +"contact deleted" = "partner törölve"; + /* No comment provided by engineer. */ "Contact deleted!" = "Partner törölve!"; +/* No comment provided by engineer. */ +"contact disabled" = "partner letiltva"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "a partner e2e titkosítással rendelkezik"; @@ -1354,6 +1390,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "Csak név"; +/* No comment provided by engineer. */ +"contact not ready" = "a kapcsolat nem áll készen"; + /* No comment provided by engineer. */ "Contact preferences" = "Partnerbeállítások"; @@ -1602,6 +1641,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat profile?" = "Törli a csevegési profilt?"; +/* alert title */ +"Delete chat with member?" = "Törli a taggal való csevegést?"; + /* No comment provided by engineer. */ "Delete chat?" = "Törli a csevegést?"; @@ -2116,6 +2158,9 @@ chat item action */ /* No comment provided by engineer. */ "Error accepting contact request" = "Hiba történt a meghívási kérés elfogadásakor"; +/* alert title */ +"Error accepting member" = "Hiba a tag befogadásakor"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Hiba történt a tag(ok) hozzáadásakor"; @@ -2173,6 +2218,9 @@ chat item action */ /* No comment provided by engineer. */ "Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; +/* alert title */ +"Error deleting chat with member" = "Hiba a taggal való csevegés törlésekor"; + /* No comment provided by engineer. */ "Error deleting chat!" = "Hiba történt a csevegés törlésekor!"; @@ -2610,6 +2658,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "A csoportmeghívó már nem érvényes, a küldője eltávolította."; +/* No comment provided by engineer. */ +"group is deleted" = "csoport törölve"; + /* No comment provided by engineer. */ "Group link" = "Csoporthivatkozás"; @@ -3138,9 +3189,15 @@ snd error text */ /* profile update event chat item */ "member %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "Tagbefogadás"; + /* rcv group event chat item */ "member connected" = "kapcsolódott"; +/* No comment provided by engineer. */ +"member has old version" = "a tag régi verziót használ"; + /* item status text */ "Member inactive" = "Inaktív tag"; @@ -3162,6 +3219,9 @@ snd error text */ /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza!"; +/* alert message */ +"Member will join the group, accept member?" = "A tag csatlakozni akar a csoporthoz, befogadja a tagot?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "A tagok reakciókat adhatnak hozzá az üzenetekhez."; @@ -3432,6 +3492,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "Új tag szerepköre"; +/* rcv group event chat item */ +"New member wants to join the group." = "Új tag szeretne csatlakozni a csoporthoz."; + /* notification */ "new message" = "új üzenet"; @@ -3471,6 +3534,9 @@ snd error text */ /* No comment provided by engineer. */ "No chats in list %@" = "Nincsenek csevegések a(z) %@ nevű listában"; +/* No comment provided by engineer. */ +"No chats with members" = "Nincsenek csevegések a tagokkal"; + /* No comment provided by engineer. */ "No contacts selected" = "Nincs partner kijelölve"; @@ -3555,6 +3621,9 @@ snd error text */ /* No comment provided by engineer. */ "Not compatible!" = "Nem kompatibilis!"; +/* No comment provided by engineer. */ +"not synchronized" = "nincs szinkronizálva"; + /* No comment provided by engineer. */ "Notes" = "Jegyzetek"; @@ -3802,6 +3871,9 @@ alert button */ /* No comment provided by engineer. */ "pending approval" = "jóváhagyásra vár"; +/* No comment provided by engineer. */ +"pending review" = "függőben lévő áttekintés"; + /* No comment provided by engineer. */ "Periodic" = "Időszakos"; @@ -3871,6 +3943,9 @@ alert button */ /* token info */ "Please try to disable and re-enable notfications." = "Próbálja meg letiltani és újra engedélyezni az értesítéseket."; +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérelmét."; + /* token info */ "Please wait for token activation to complete." = "Várjon, amíg a token aktiválása befejeződik."; @@ -4151,6 +4226,9 @@ swipe action */ /* No comment provided by engineer. */ "Reject contact request" = "Meghívási kérés elutasítása"; +/* alert title */ +"Reject member?" = "Elutasítja a tagot?"; + /* No comment provided by engineer. */ "rejected" = "elutasítva"; @@ -4175,7 +4253,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Eltávolítás"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Eltávolítja a tagot?"; /* No comment provided by engineer. */ @@ -4190,6 +4268,9 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "eltávolította a kapcsolattartási címet"; +/* No comment provided by engineer. */ +"removed from group" = "eltávolítva a csoportból"; + /* profile update event chat item */ "removed profile picture" = "eltávolította a profilképét"; @@ -4238,6 +4319,9 @@ swipe action */ /* No comment provided by engineer. */ "Report reason?" = "Jelentés indoklása?"; +/* alert title */ +"Report sent to moderators" = "A jelentés el lett küldve a moderátoroknak"; + /* report reason */ "Report spam: only group moderators will see it." = "Kéretlen tartalom jelentése: csak a csoport moderátorai látják."; @@ -4253,6 +4337,9 @@ swipe action */ /* No comment provided by engineer. */ "Reports" = "Jelentések"; +/* No comment provided by engineer. */ +"request to join rejected" = "csatlakozási kérelem elutasítva"; + /* chat list item title */ "requested to connect" = "Függőben lévő meghívási kérelem"; @@ -4307,9 +4394,21 @@ swipe action */ /* chat item action */ "Reveal" = "Felfedés"; +/* No comment provided by engineer. */ +"review" = "áttekintés"; + /* No comment provided by engineer. */ "Review conditions" = "Feltételek felülvizsgálata"; +/* admission stage */ +"Review members" = "Tagok áttekintése"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Tagok áttekintése a befogadás előtt (kopogtatás)."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "áttekintve a moderátorok által"; + /* No comment provided by engineer. */ "Revoke" = "Visszavonás"; @@ -4338,6 +4437,9 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "Mentés (és a partnerek értesítése)"; +/* alert title */ +"Save admission settings?" = "Elmenti a befogadási beállításokat?"; + /* alert button */ "Save and notify contact" = "Mentés és a partner értesítése"; @@ -4674,6 +4776,9 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Beállítás a rendszer-hitelesítés helyett."; +/* No comment provided by engineer. */ +"Set member admission" = "Tagbefogadás beállítása"; + /* No comment provided by engineer. */ "Set message expiration in chats." = "Üzenetek eltűnési idejének módosítása a csevegésekben."; @@ -5714,6 +5819,9 @@ report reason */ /* No comment provided by engineer. */ "You accepted connection" = "Kapcsolat létrehozása"; +/* snd group event chat item */ +"you accepted this member" = "Ön befogadta ezt a tagot"; + /* No comment provided by engineer. */ "You allow" = "Ön engedélyezi"; @@ -5825,6 +5933,9 @@ report reason */ /* alert message */ "You can view invitation link again in connection details." = "A meghívási hivatkozást újra megtekintheti a kapcsolat részleteinél."; +/* alert message */ +"You can view your reports in Chat with admins." = "A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben."; + /* No comment provided by engineer. */ "You can't send messages!" = "Nem lehet üzeneteket küldeni!"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index dfa4c81504..1a1ca0e8a6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1026,7 +1026,7 @@ frissítette a csoport profilját SIMPLEX CHAT TÁMOGATÁSA SimpleX Chat szolgáltatás - Nem lehet üzeneteket küldeni! + Ön megfigyelő %s hitelesítve Jelszó a megjelenítéshez Adatvédelem és biztonság @@ -2361,7 +2361,7 @@ %d csevegés A jelentés el lett küldve a moderátoroknak A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben. - áttekintésre vár + függőben lévő áttekintés áttekintés Csevegés az adminisztrátorokkal Csevegés a tagokkal @@ -2387,4 +2387,20 @@ Befogadás tagként befogadta őt: %1$s áttekintve a moderátorok által + nem lehet üzeneteket küldeni + partner letiltva + csoport törölve + eltávolítva a csoportból + csatlakozási kérelem elutasítva + Ön elhagyta a csoportot + a tag régi verziót használ + Hiba a taggal való csevegés törlésekor + Ön nem tud üzeneteket küldeni! + a kapcsolat nem áll készen + nincs szinkronizálva + Törli a taggal való csevegést? + partner törölve + Csevegés törlése + Elutasítás + Elutasítja a tagot? From f162f96be8136724b48546c4234ebbad3f2b896e Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 20 May 2025 15:44:33 +0100 Subject: [PATCH 57/72] website: translations (#5932) * Translated using Weblate (Ukrainian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/uk/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 78.5% (202 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 78.5% (202 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Russian) Currently translated at 99.6% (256 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ --------- Co-authored-by: Bezruchenko Simon Co-authored-by: dns Co-authored-by: 4 Bi 5aYzVk 93FCVjWLWxh44XH3984teVSfjwFYmUGUrbvnHwGirk9 Co-authored-by: summoner001 Co-authored-by: Vitaly Kanevsky Co-authored-by: Ghost of Sparta --- website/langs/hu.json | 30 +++++++++++++++--------------- website/langs/ru.json | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/website/langs/hu.json b/website/langs/hu.json index 9dfd3d9f65..a55136d26a 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -31,7 +31,7 @@ "hero-overlay-2-textlink": "Hogyan működik a SimpleX?", "hero-overlay-3-textlink": "Biztonsági felmérések", "hero-2-header": "Privát kapcsolat létrehozása", - "hero-2-header-desc": "A videó bemutatja, hogyan kapcsolódhat a partneréhez egy egyszer használható QR-kód segítségével, személyesen vagy videokapcsolaton keresztül. Ugyanakkor egy meghívási hivatkozás megosztásával is kapcsolódhat.", + "hero-2-header-desc": "A videó bemutatja, hogyan kapcsolódhat a partneréhez egy egyszer használható QR-kód segítségével, személyesen vagy videóhíváson keresztül. Ugyanakkor egy meghívási hivatkozás megosztásával is kapcsolódhat.", "hero-overlay-1-title": "Hogyan működik a SimpleX?", "hero-overlay-2-title": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "hero-overlay-3-title": "Biztonsági felmérések", @@ -40,14 +40,14 @@ "feature-3-title": "E2E-titkosított decentralizált csoportok — csak a felhasználók tudják, hogy ezek léteznek", "feature-4-title": "E2E-titkosított hangüzenetek", "feature-5-title": "Eltűnő üzenetek", - "feature-6-title": "E2E-titkosított
hang- és videohívások", + "feature-6-title": "E2E-titkosított
hang- és videóhívások", "feature-7-title": "Hordozható titkosított alkalmazás-adattárolás — profil átköltöztetése egy másik eszközre", "feature-8-title": "Az inkognitómód —
egyedülálló a SimpleX Chatben", "simplex-network-overlay-1-title": "Összehasonlítás más P2P-üzenetküldő protokollokkal", "simplex-private-1-title": "2 rétegű végpontok közötti titkosítás", "simplex-private-2-title": "További rétege a
kiszolgáló-titkosítás", - "simplex-private-4-title": "Nem kötelező
hozzáférés Tor-on keresztül", - "simplex-private-5-title": "Több rétegű
tartalom kitöltés", + "simplex-private-4-title": "Hozzáférés Tor-hálózaton keresztül
(nem kötelező)", + "simplex-private-5-title": "Több rétegű
tartalomkitöltés", "simplex-private-6-title": "Sávon kívüli
kulcscsere", "simplex-private-7-title": "Üzenetintegritás
hitelesítés", "simplex-private-8-title": "Üzenetek keverése
a korreláció csökkentése érdekében", @@ -59,14 +59,14 @@ "simplex-private-card-3-point-1": "A kliens és a kiszolgálók közötti kapcsolatokhoz csak az erős algoritmusokkal rendelkező TLS 1.2/1.3 protokollt használja.", "simplex-private-card-3-point-2": "A kiszolgáló ujjlenyomata és a csatornakötés megakadályozza a MITM- és a visszajátszási támadásokat.", "simplex-private-card-3-point-3": "Az újrakapcsolódás le van tiltva a munkamenet elleni támadások megelőzése érdekében.", - "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a Tor-on vagy más átvitel-átfedő-hálózaton keresztül is elérheti.", + "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a Tor hálózaton vagy más átvitelátfedő hálózaton keresztül is elérheti.", "simplex-private-card-6-point-1": "Számos kommunikációs hálózat sebezhető a kiszolgálók vagy a hálózat-szolgáltatók MITM-támadásaival szemben.", "simplex-private-card-6-point-2": "Ennek megakadályozása érdekében a SimpleX-alkalmazások egyszeri kulcsokat adnak át sávon kívül, amikor egy címet hivatkozásként vagy QR-kódként oszt meg.", "simplex-private-card-7-point-1": "Az integritás garantálása érdekében az üzenetek sorszámozással vannak ellátva, és tartalmazzák az előző üzenet hasítóértékét.", "simplex-private-card-7-point-2": "Ha bármilyen üzenetet hozzáadnak, eltávolítanak vagy módosítanak, a címzett értesítést kap róla.", "simplex-private-card-8-point-1": "A SimpleX-kiszolgálók alacsony késleltetésű keverési csomópontokként működnek — a bejövő és kimenő üzenetek sorrendje eltérő.", "simplex-private-card-9-point-1": "Minden üzenetsorbaállítás egy irányba továbbítja az üzeneteket, a különböző küldési és vételi címekkel.", - "simplex-private-card-9-point-2": "A hagyományos üzenetküldőkhöz képest csökkenti a támadási vektorokat és a rendelkezésre álló metaadatokat.", + "simplex-private-card-9-point-2": "Kevesebb támadási felülettel rendelkezik, mint a hagyományos üzenetváltó alkalmazások, és kevesebb metaadatot tesz elérhetővé.", "simplex-private-card-10-point-1": "A SimpleX ideiglenes, névtelen, páros címeket és hitelesítő adatokat használ minden egyes felhasználói kapcsolathoz vagy csoporttaghoz.", "simplex-private-card-10-point-2": "Lehetővé teszi az üzenetek felhasználói profilazonosítók nélküli kézbesítését, ami az alternatíváknál jobb metaadat-védelmet biztosít.", "privacy-matters-1-overlay-1-title": "Az adatvédelemmel pénzt spórol meg", @@ -95,7 +95,7 @@ "hero-overlay-card-2-p-3": "Még a Tor v3 szolgáltatásokat használó, legprivátabb alkalmazások esetében is, ha két különböző kapcsolattartóval beszél ugyanazon a profilon keresztül, bizonyítani tudják, hogy ugyanahhoz a személyhez kapcsolódnak.", "hero-overlay-card-2-p-4": "A SimpleX úgy védekezik ezen támadások ellen, hogy nem tartalmaz felhasználói azonosítókat. Ha pedig használja az inkognitómódot, akkor minden egyes létrejött kapcsolatban más-más felhasználó név jelenik meg, így elkerülhető a közöttük lévő összefüggések teljes bizonyítása.", "hero-overlay-card-3-p-1": "Trail of Bits egy vezető biztonsági és technológiai tanácsadó cég, amelynek az ügyfelei közé tartoznak nagy technológiai cégek, kormányzati ügynökségek és jelentős blokklánc projektek.", - "hero-overlay-card-3-p-2": "A Trail of Bits 2022 novemberében áttekintette a SimpleX-platform kriptográfiai és hálózati komponenseit. További információk.", + "hero-overlay-card-3-p-2": "A Trail of Bits 2022 novemberében auditálta a SimpleX-platform kriptográfiai és hálózati komponenseit. További információk.", "simplex-network-overlay-card-1-li-1": "A P2P-hálózatok az üzenetek továbbítására a DHT valamelyik változatát használják. A DHT kialakításakor egyensúlyt kell teremteni a kézbesítési garancia és a késleltetés között. A SimpleX jobb kézbesítési garanciával és alacsonyabb késleltetéssel rendelkezik, mint a P2P, mivel az üzenet redundánsan, a címzett által kiválasztott kiszolgálók segítségével több kiszolgálón keresztül párhuzamosan továbbítható. A P2P-hálózatokban az üzenet O(log N) csomóponton halad át szekvenciálisan, az algoritmus által kiválasztott csomópontok segítségével.", "simplex-network-overlay-card-1-li-2": "A SimpleX kialakítása a legtöbb P2P-hálózattól eltérően nem rendelkezik semmiféle globális felhasználói azonosítóval, még ideiglenessel sem, és csak az üzenetekhez használ ideiglenes, páros azonosítókat, ami jobb névtelenséget és metaadatvédelmet biztosít.", "simplex-network-overlay-card-1-li-3": "A P2P nem oldja meg a MITM-támadás problémát, és a legtöbb létező implementáció nem használ sávon kívüli üzeneteket a kezdeti kulcscseréhez. A SimpleX a kezdeti kulcscseréhez sávon kívüli üzeneteket, vagy bizonyos esetekben már meglévő biztonságos és megbízható kapcsolatokat használ.", @@ -139,7 +139,7 @@ "sign-up-to-receive-our-updates": "Regisztráljon a hírleveleinkre, hogy ne maradjon le semmiről", "enter-your-email-address": "Adja meg az e-mail-címét", "get-simplex": "A SimpleX számítógépes alkalmazásának letöltése", - "why-simplex-is-unique": "Mitől egyedülálló a SimpleX", + "why-simplex-is-unique": "A SimpleX mitől egyedülálló", "learn-more": "Tudjon meg többet", "more-info": "További információ", "hide-info": "Információ elrejtése", @@ -164,7 +164,7 @@ "simplex-chat-for-the-terminal": "SimpleX Chat a terminálhoz", "copy-the-command-below-text": "másolja be az alábbi parancsot, és használja a csevegésben:", "privacy-matters-section-header": "Miért számít az adatvédelem", - "privacy-matters-section-subheader": "A metaadatok védelmének megőrzése — kivel beszélget — megvédi a következőktől:", + "privacy-matters-section-subheader": "A metaadatok — pédául, hogy kivel beszélget — védelmének megőrzése biztonságot nyújt a következők ellen:", "privacy-matters-section-label": "Győződjön meg arról, hogy az üzenetváltó-alkalmazás amit használ nem fér hozzá az adataihoz!", "simplex-private-section-header": "Mitől lesz a SimpleX privát", "simplex-network-section-header": "SimpleX-hálózat", @@ -202,7 +202,7 @@ "guide-dropdown-3": "Titkos csoportok", "guide-dropdown-4": "Csevegési profilok", "guide-dropdown-5": "Adatkezelés", - "guide-dropdown-6": "Hang- és videó hívások", + "guide-dropdown-6": "Hang- és videóhívások", "guide-dropdown-7": "Adatvédelem és biztonság", "guide-dropdown-8": "Alkalmazás beállításai", "guide": "Útmutató", @@ -223,8 +223,8 @@ "contact-hero-header": "Kapott egy meghívót a SimpleX Chaten való beszélgetéshez", "invitation-hero-header": "Kapott egy egyszer használható meghívót a SimpleX Chaten való beszélgetéshez", "simplex-network-overlay-card-1-li-4": "A P2P-megvalósításokat egyes internetszolgáltatók blokkolhatják (mint például a BitTorrent). A SimpleX átvitel-független — a szabványos webes protokollokon, például WebSocketsen keresztül is működik.", - "simplex-private-card-4-point-2": "A SimpleX Tor-on keresztüli használatához telepítse az Orbot alkalmazást és engedélyezze a SOCKS5 proxyt (vagy a VPN-t az iOS-ban).", - "simplex-private-card-5-point-1": "A SimpleX minden titkosítási réteghez tartalomkitöltést használ, hogy meghiúsítsa az üzenetméret ellen irányuló támadásokat.", + "simplex-private-card-4-point-2": "A SimpleX, Tor-hálózaton keresztüli használatához telepítse az Orbot alkalmazást és engedélyezze a SOCKS5 proxyt (vagy a VPN-t az iOS-ban).", + "simplex-private-card-5-point-1": "A SimpleX minden titkosítási réteghez tartalomkitöltést használ az üzenetméretre irányuló támadások meghiúsítása érdekében.", "simplex-private-card-5-point-2": "A kiszolgálók és a hálózatot megfigyelők számára a különböző méretű üzenetek egyformának tűnnek.", "privacy-matters-1-title": "Hirdetés és árdiszkrimináció", "hero-overlay-card-1-p-3": "Ön határozza meg, hogy melyik kiszolgáló(ka)t használja az üzenetek fogadására, a kapcsolatokhoz — azokat a kiszolgálókat, amelyeket az üzenetek küldésére használ. Minden beszélgetés két különböző kiszolgálót használ.", @@ -232,7 +232,7 @@ "chat-bot-example": "Példa csevegési botra", "simplex-private-3-title": "Biztonságos, hitelesített
TLS adatátvitel", "github-repository": "GitHub tárolójában", - "tap-to-close": "Koppintson a bezáráshoz", + "tap-to-close": "Bezárás", "simplex-network-1-header": "A P2P-hálózatokkal ellentétben", "simplex-network-1-overlay-linktext": "a P2P-hálózatok problémái", "comparison-point-3-text": "Függés a DNS-től", @@ -254,6 +254,6 @@ "simplex-chat-via-f-droid": "SimpleX Chat az F-Droidon keresztül", "simplex-chat-repo": "A SimpleX Chat tárolója", "stable-and-beta-versions-built-by-developers": "A fejlesztők által készített stabil és béta verziók", - "hero-overlay-card-3-p-3": "A Trail of Bits 2024 júliusában felülvizsgálta a SimpleX hálózati protokollok kriptográfiai felépítését. Tudjon meg többet.", + "hero-overlay-card-3-p-3": "A Trail of Bits 2024 júliusában ismét auditálta a SimpleX-protokollok kriptográfiai és hálózati komponenseit. További információk.", "docs-dropdown-14": "SimpleX üzleti célra" -} +} \ No newline at end of file diff --git a/website/langs/ru.json b/website/langs/ru.json index 335b19ab11..7f8e191ccd 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -149,7 +149,7 @@ "back-to-top": "Вернуться к началу", "simplex-network-1-desc": "Все сообщения отправляются через серверы, что обеспечивает лучшую конфиденциальность метаданных и надежную асинхронную доставку сообщений, избегая при этом многих", "simplex-chat-repo": "Репозиторий SimpleX Chat", - "simplex-private-card-6-point-1": "Многие коммуникационные платформы уязвимы для MITM-атак со стороны серверов или сетевых провайдеров.", + "simplex-private-card-6-point-1": "Многие коммуникационные сети уязвимы для MITM-атак со стороны серверов или сетевых провайдеров.", "privacy-matters-3-overlay-1-linkText": "Конфиденциальность защищает Вашу свободу", "simplex-unique-overlay-card-1-p-2": "Для доставки сообщений SimpleX использует попарные, анонимные адреса однонаправленных очередей сообщений, раздельные для полученных и отправленных сообщений, обычно через разные серверы.", "simplex-unique-overlay-card-3-p-4": "Со стороны не видно разницы между отправлением или получением сообщений — если кто-то наблюдает за этим, он не cможет легко определить, кто с кем общается, даже если протокол TLS будет скомпрометирован.", From e84016e1b9531ab49a2139974c0b81e6f2712e23 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 20 May 2025 16:18:23 +0000 Subject: [PATCH 58/72] core, ui: update group scope unread stats on item deletions; create reports as read (reports use a different attention mechanism) (#5930) --- apps/ios/Shared/Model/SimpleXAPI.swift | 3 + apps/ios/Shared/Views/Chat/ChatView.swift | 7 ++ .../chat/simplex/common/model/SimpleXAPI.kt | 5 + .../simplex/common/views/chat/ChatView.kt | 7 ++ src/Simplex/Chat/Library/Commands.hs | 10 +- src/Simplex/Chat/Library/Internal.hs | 37 +++++- src/Simplex/Chat/Messages.hs | 24 +++- src/Simplex/Chat/Messages/CIContent.hs | 3 + src/Simplex/Chat/Store/Groups.hs | 2 +- src/Simplex/Chat/Store/Messages.hs | 112 +++++++++--------- .../SQLite/Migrations/chat_query_plans.txt | 29 ++--- tests/ChatTests/Groups.hs | 37 +++++- 12 files changed, 186 insertions(+), 90 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 8621baaade..6b938aaa4d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2206,6 +2206,9 @@ func processReceivedMsg(_ res: ChatEvent) async { m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id) } } + if let updatedChatInfo = items.last?.deletedChatItem.chatInfo { + m.updateChatInfo(updatedChatInfo) + } } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 093827ac59..8ce0c50849 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2277,6 +2277,7 @@ struct ChatView: View { if deletedItem.isActiveReport { m.decreaseGroupReportsCounter(chat.chatInfo.id) } + m.updateChatInfo(itemDeletion.deletedChatItem.chatInfo) } } } @@ -2466,6 +2467,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id) } } + if let updatedChatInfo = deletedItems.last?.deletedChatItem.chatInfo { + ChatModel.shared.updateChatInfo(updatedChatInfo) + } } await onSuccess() } catch { @@ -2497,6 +2501,9 @@ func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id) } } + if let updatedChatInfo = deleted.last?.deletedChatItem.chatInfo { + ChatModel.shared.updateChatInfo(updatedChatInfo) + } } await onSuccess() } catch { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index c3b681dbc7..7cb2d9fe5e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2533,6 +2533,11 @@ object ChatController { } } } + r.chatItemDeletions.lastOrNull()?.deletedChatItem?.chatInfo?.let { updatedChatInfo -> + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateChatInfo(rhId, updatedChatInfo) + } + } } is CR.GroupChatItemsDeleted -> { groupChatItemsDeleted(rhId, r) 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 c51b5ff622..2ca0dcc35d 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 @@ -455,6 +455,7 @@ fun ChatView( if (deletedItem.isActiveReport) { chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id) } + chatModel.chatsContext.updateChatInfo(chatRh, deleted.deletedChatItem.chatInfo) } withContext(Dispatchers.Main) { if (toChatItem != null) { @@ -2672,6 +2673,9 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List + chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + } } withContext(Dispatchers.Main) { for (di in deleted) { @@ -2712,6 +2716,9 @@ private fun archiveReports(chatRh: Long?, chatInfo: ChatInfo, itemIds: List + chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + } } withContext(Dispatchers.Main) { for (di in deleted) { diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 79714af0ec..20f9468cd6 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1032,8 +1032,9 @@ processChatCommand' vr = \case user <- getUserByGroupId db chatId gInfo <- getGroupInfo db vr user chatId pure (user, gInfo) + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope (timedItems, gInfo') <- withFastStore $ \db -> do - (timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo scope itemIds + (timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo chatScopeInfo itemIds timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime pure (timedItems', gInfo') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt @@ -2239,7 +2240,7 @@ processChatCommand' vr = \case deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 -- Read group info with updated membersRequireAttention gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId - let acis' = map (updateCIGroupInfo gInfo') acis + let acis' = map (updateACIGroupInfo gInfo') acis unless (null acis') $ toView $ CEvtNewChatItems user acis' unless (null errs) $ toView $ CEvtChatErrors errs when withMessages $ deleteMessages user gInfo' deleted @@ -2299,11 +2300,6 @@ processChatCommand' vr = \case -- instead we re-read it once after deleting all members before response. void $ deleteOrUpdateMemberRecordIO db user gInfo m pure m {memberStatus = GSMemRemoved} - updateCIGroupInfo :: GroupInfo -> AChatItem -> AChatItem - updateCIGroupInfo gInfo' = \case - AChatItem SCTGroup SMDSnd (GroupChat _gInfo chatScopeInfo) ci -> - AChatItem SCTGroup SMDSnd (GroupChat gInfo' chatScopeInfo) ci - aci -> aci deleteMessages user gInfo@GroupInfo {membership} ms | groupFeatureMemberAllowed SGFFullDelete membership gInfo = deleteGroupMembersCIs user gInfo ms membership | otherwise = markGroupMembersCIsDeleted user gInfo ms membership diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index d3a0e6e59f..bdc5e4b920 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -37,7 +37,7 @@ import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) -import Data.List (find, mapAccumL, partition) +import Data.List (find, foldl', mapAccumL, partition) import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -459,7 +459,14 @@ 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 - pure deletions + vr <- chatVersionRange + 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 + pure $ map (updateDeletionGroupInfo gInfo') deletions + pure deletions' where deleteItem :: DB.Connection -> CChatItem 'CTGroup -> IO ChatItemDeletion deleteItem db (CChatItem md ci) = do @@ -467,6 +474,32 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs Nothing -> Nothing <$ deleteGroupChatItem db user gInfo ci pure $ groupDeletion md gInfo chatScopeInfo ci ci' + countDeletedUnreadItems :: Maybe GroupMember -> [ChatItemDeletion] -> (Int, Int, Int) + countDeletedUnreadItems scopeMember_ = foldl' countItem (0, 0, 0) + where + countItem :: (Int, Int, Int) -> ChatItemDeletion -> (Int, Int, Int) + countItem (!unread, !unanswered, !mentions) ChatItemDeletion {deletedChatItem} + | aChatItemIsRcvNew deletedChatItem = + let unread' = unread + 1 + unanswered' = case (scopeMember_, aChatItemRcvFromMember deletedChatItem) of + (Just scopeMember, Just rcvFromMember) + | groupMemberId' rcvFromMember == groupMemberId' scopeMember -> unanswered + 1 + _ -> unanswered + mentions' = if isACIUserMention deletedChatItem then mentions + 1 else mentions + in (unread', unanswered', mentions') + | otherwise = (unread, unanswered, mentions) + updateDeletionGroupInfo :: GroupInfo -> ChatItemDeletion -> ChatItemDeletion + updateDeletionGroupInfo gInfo' ChatItemDeletion {deletedChatItem, toChatItem} = + ChatItemDeletion + { deletedChatItem = updateACIGroupInfo gInfo' deletedChatItem, + toChatItem = updateACIGroupInfo gInfo' <$> toChatItem + } + +updateACIGroupInfo :: GroupInfo -> AChatItem -> AChatItem +updateACIGroupInfo gInfo' = \case + AChatItem SCTGroup dir (GroupChat _gInfo chatScopeInfo) ci -> + AChatItem SCTGroup dir (GroupChat gInfo' chatScopeInfo) ci + aci -> aci deleteGroupMemberCIs :: MsgDirectionI d => User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> CM () deleteGroupMemberCIs user gInfo member byGroupMember msgDir = do diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 9a8dc786cd..dfd37527d1 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -215,6 +215,9 @@ data CIMentionMember = CIMentionMember } deriving (Eq, Show) +isACIUserMention :: AChatItem -> Bool +isACIUserMention (AChatItem _ _ _ ci) = isUserMention ci + isUserMention :: ChatItem c d -> Bool isUserMention ChatItem {meta = CIMeta {userMention}} = userMention @@ -295,6 +298,16 @@ chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of CIGroupSnd -> membership CIGroupRcv m -> m +chatItemRcvFromMember :: ChatItem c d -> Maybe GroupMember +chatItemRcvFromMember ChatItem {chatDir} = case chatDir of + CIGroupRcv m -> Just m + _ -> Nothing + +chatItemIsRcvNew :: ChatItem c d -> Bool +chatItemIsRcvNew ChatItem {meta = CIMeta {itemStatus}} = case itemStatus of + CISRcvNew -> True + _ -> False + ciReactionAllowed :: ChatItem c d -> Bool ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content @@ -395,6 +408,12 @@ aChatItemTs (AChatItem _ _ _ ci) = chatItemTs' ci aChatItemDir :: AChatItem -> MsgDirection aChatItemDir (AChatItem _ sMsgDir _ _) = toMsgDirection sMsgDir +aChatItemRcvFromMember :: AChatItem -> Maybe GroupMember +aChatItemRcvFromMember (AChatItem _ _ _ ci) = chatItemRcvFromMember ci + +aChatItemIsRcvNew :: AChatItem -> Bool +aChatItemIsRcvNew (AChatItem _ _ _ ci) = chatItemIsRcvNew ci + updateFileStatus :: forall c d. ChatItem c d -> CIFileStatus d -> ChatItem c d updateFileStatus ci@ChatItem {file} status = case file of Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}} @@ -966,7 +985,10 @@ ciStatusNew = case msgDirection @d of ciCreateStatus :: forall d. MsgDirectionI d => CIContent d -> CIStatus d ciCreateStatus content = case msgDirection @d of SMDSnd -> ciStatusNew - SMDRcv -> if ciRequiresAttention content then ciStatusNew else CISRcvRead + SMDRcv + | isCIReport content -> CISRcvRead + | ciRequiresAttention content -> ciStatusNew + | otherwise -> CISRcvRead membersGroupItemStatus :: [(GroupSndStatus, Int)] -> CIStatus 'MDSnd membersGroupItemStatus memStatusCounts diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index cc2f97ac44..cc6529831c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -182,6 +182,9 @@ ciMsgContent = \case CIRcvMsgContent mc -> Just mc _ -> Nothing +isCIReport :: CIContent d -> Bool +isCIReport = maybe False isReport . ciMsgContent + data MsgDecryptError = MDERatchetHeader | MDETooManySkipped diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4470641491..6c66ea0e64 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1233,7 +1233,7 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} deleteGroupMemberSupportChat :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) -deleteGroupMemberSupportChat db user@User {userId} g@GroupInfo {groupId, membersRequireAttention} m@GroupMember {groupMemberId, supportChat} = do +deleteGroupMemberSupportChat db user g m@GroupMember {groupMemberId} = do let requiredAttention = gmRequiresAttention m currentTs <- getCurrentTime DB.execute diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d6affc4f12..a9bd03ed0e 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -80,6 +80,7 @@ module Simplex.Chat.Store.Messages updateGroupChatItemsRead, getGroupUnreadTimedItems, updateGroupChatItemsReadList, + updateGroupScopeUnreadStats, setGroupChatItemsDeleteAt, updateLocalChatItemsRead, getChatRefViaItemId, @@ -2018,13 +2019,17 @@ getGroupUnreadTimedItems db User {userId} groupId = |] (userId, groupId, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) -updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId, membership, membersRequireAttention} scope itemIds = do +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 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. readItemsData <- liftIO $ catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds - g' <- updateChatStats readItemsData + g' <- case scopeInfo_ of + Nothing -> pure g + Just scopeInfo@GCSIMemberSupport {groupMember_} -> do + let decStats = countReadItems groupMember_ readItemsData + liftIO $ updateGroupScopeUnreadStats db vr user g scopeInfo decStats pure (timedItems readItemsData, g') where getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) @@ -2038,67 +2043,58 @@ updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId, memb RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) - updateChatStats :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> ExceptT StoreError IO GroupInfo - updateChatStats readItemsData = case scope of - Nothing -> pure g - Just GCSMemberSupport {groupMemberId_} -> case groupMemberId_ of - Nothing -> do - membership' <- updateGMStats membership - pure g {membership = membership'} - Just groupMemberId -> do - member <- getGroupMemberById db vr user groupMemberId - member' <- updateGMStats member - let didRequire = gmRequiresAttention member - nowRequires = gmRequiresAttention member' - if (not nowRequires && didRequire) - then do - liftIO $ - DB.execute - db - [sql| - UPDATE groups - SET members_require_attention = members_require_attention - 1 - WHERE user_id = ? AND group_id = ? - |] - (userId, groupId) - pure g {membersRequireAttention = membersRequireAttention - 1} - else - pure g - where - updateGMStats GroupMember {groupMemberId} = do - let unread = length readItemsData - (unanswered, mentions) = decStats - liftIO $ - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ? - WHERE group_member_id = ? - |] - (unread, unanswered, mentions, groupMemberId) - getGroupMemberById db vr user groupMemberId - where - decStats :: (Int, Int) - decStats = foldl' countItem (0, 0) readItemsData - where - countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) - countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = - let unanswered' = case (groupMemberId_, itemGMId_) of - (Just scopeGMId, Just itemGMId) | itemGMId == scopeGMId -> unanswered + 1 - _ -> unanswered - mentions' = case userMention_ of - Just (BI True) -> mentions + 1 - _ -> mentions - in (unanswered', mentions') + countReadItems :: Maybe GroupMember -> [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> (Int, Int, Int) + countReadItems scopeMember_ readItemsData = + let unread = length readItemsData + (unanswered, mentions) = foldl' countItem (0, 0) readItemsData + in (unread, unanswered, mentions) + where + countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) + countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = + let unanswered' = case (scopeMember_, itemGMId_) of + (Just scopeMember, Just itemGMId) | itemGMId == groupMemberId' scopeMember -> unanswered + 1 + _ -> unanswered + mentions' = case userMention_ of + Just (BI True) -> mentions + 1 + _ -> mentions + in (unanswered', mentions') timedItems :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> [(ChatItemId, Int)] timedItems = foldl' addTimedItem [] where 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) = + case scopeInfo of + GCSIMemberSupport {groupMember_} -> case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + pure g {membership = membership'} + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if (not nowRequires && didRequire) + then decreaseGroupMembersRequireAttention db user g + else pure g + where + updateGMStats m@GroupMember {groupMemberId} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ?, + updated_at = ? + WHERE group_member_id = ? + |] + (unread, unanswered, mentions, currentTs, groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + deriving instance Show BoolInt setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] 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 cf3c3fb206..b0e98856d9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1,21 +1,3 @@ -Query: - UPDATE groups - SET members_require_attention = members_require_attention - 1 - WHERE user_id = ? AND group_id = ? - -Plan: -SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE groups SET chat_ts = ?, @@ -1321,6 +1303,17 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ?, + updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_profiles SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index d1588ce6fb..1cf6d0d0d6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -196,7 +196,8 @@ chatGroupTests = do it "should send scoped messages to support (single moderator)" testScopedSupportSingleModerator it "should send scoped messages to support (many moderators)" testScopedSupportManyModerators it "should send messages to admins and members" testSupportCLISendCommand - it "should correctly maintain unread stats for support chats" testScopedSupportUnreadStats + it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead + it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -7009,8 +7010,8 @@ testSupportCLISendCommand = bob ##> "#team (support 4" bob <## "bad chat command: Failed reading: empty" -testScopedSupportUnreadStats :: HasCallStack => TestParams -> IO () -testScopedSupportUnreadStats = +testScopedSupportUnreadStatsOnRead :: HasCallStack => TestParams -> IO () +testScopedSupportUnreadStatsOnRead = testChatOpts4 opts aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) @@ -7149,3 +7150,33 @@ testScopedSupportUnreadStats = { markRead = False } +testScopedSupportUnreadStatsOnDelete :: HasCallStack => TestParams -> IO () +testScopedSupportUnreadStatsOnDelete = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2 "team" alice bob + + alice ##> "/set delete #team on" + alice <## "updated group preferences:" + alice <## "Full deletion: on" + bob <## "alice updated group #team:" + bob <## "updated group preferences:" + bob <## "Full deletion: on" + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + msgIdBob <- lastItemId bob + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" + + bob #$> ("/_delete item #1(_support) " <> msgIdBob <> " broadcast", id, "message deleted") + alice <# "#team (support: bob) bob> [deleted] 1" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + where + opts = + testOpts + { markRead = False + } From 0e99e8010ad31de65aa8c0fc471c6692b7549ef7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 21 May 2025 10:12:53 +0100 Subject: [PATCH 59/72] core: 6.4.0.2 (simplexmq 6.4.0.4) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cabal.project b/cabal.project index 44305353f2..48b75a86cd 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f44ea0a6d8eec8abf4af177ebeb91629f7d89165 + tag: d352d518c2b3a42bc7a298954dde799422e1457f source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0c29b47a36..68a6054ef0 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f44ea0a6d8eec8abf4af177ebeb91629f7d89165" = "1biq1kq33v7hnacbhllry9n5c6dmh9dyqnz8hc5abgsv1z38qb1a"; + "https://github.com/simplex-chat/simplexmq.git"."d352d518c2b3a42bc7a298954dde799422e1457f" = "1rha84pfpaqx3mf218szkfra334vhijqf17hanxqmp1sicfbf1x3"; "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 7b09e9c926..553f2ec6cd 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.4.0.1 +version: 6.4.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 753e4543d6..97bf26fd84 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -841,7 +841,7 @@ data ChatEvent | CEvtRemoteHostConnected {remoteHost :: RemoteHostInfo} | CEvtRemoteHostStopped {remoteHostId_ :: Maybe RemoteHostId, rhsState :: RemoteHostSessionState, rhStopReason :: RemoteHostStopReason} | CEvtRemoteCtrlFound {remoteCtrl :: RemoteCtrlInfo, ctrlAppInfo_ :: Maybe CtrlAppInfo, appVersion :: AppVersion, compatible :: Bool} - | CEvtRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} + | CEvtRemoteCtrlSessionCode {remoteCtrl_ :: Maybe RemoteCtrlInfo, sessionCode :: Text} | CEvtRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} | CEvtContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CEvtContactDisabled {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index b71b103919..f6c94badfe 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 4, 0, 1] +minRemoteCtrlVersion = AppVersion [6, 4, 0, 2] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 4, 0, 1] +minRemoteHostVersion = AppVersion [6, 4, 0, 2] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From cef63f2c87801f05445ef484c71c7229636a7b55 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 21 May 2025 11:47:45 +0100 Subject: [PATCH 60/72] ui: translations (#5936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) Currently translated at 100.0% (2404 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Russian) Currently translated at 98.3% (2365 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (German) Currently translated at 100.0% (2090 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2404 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (2090 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2404 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Russian) Currently translated at 100.0% (2404 of 2404 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (2090 of 2090 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * ios: process localizations * update --------- Co-authored-by: mlanp Co-authored-by: Vitaly Kanevsky Co-authored-by: Random Co-authored-by: 大王叫我来巡山 --- .../de.xcloc/Localized Contents/de.xliff | 37 +++ .../it.xcloc/Localized Contents/it.xliff | 37 +++ .../ru.xcloc/Localized Contents/ru.xliff | 103 +++++- .../SimpleX NSE/ru.lproj/Localizable.strings | 3 + apps/ios/bg.lproj/Localizable.strings | 2 +- apps/ios/cs.lproj/Localizable.strings | 2 +- apps/ios/de.lproj/Localizable.strings | 113 ++++++- apps/ios/es.lproj/Localizable.strings | 2 +- apps/ios/fi.lproj/Localizable.strings | 2 +- apps/ios/fr.lproj/Localizable.strings | 2 +- apps/ios/it.lproj/Localizable.strings | 113 ++++++- apps/ios/ja.lproj/Localizable.strings | 2 +- apps/ios/nl.lproj/Localizable.strings | 2 +- apps/ios/pl.lproj/Localizable.strings | 2 +- apps/ios/ru.lproj/Localizable.strings | 297 +++++++++++++++++- apps/ios/th.lproj/Localizable.strings | 2 +- apps/ios/tr.lproj/Localizable.strings | 2 +- apps/ios/uk.lproj/Localizable.strings | 2 +- apps/ios/zh-Hans.lproj/Localizable.strings | 2 +- .../commonMain/resources/MR/de/strings.xml | 21 +- .../commonMain/resources/MR/it/strings.xml | 23 +- .../commonMain/resources/MR/ru/strings.xml | 58 +++- .../resources/MR/zh-rCN/strings.xml | 17 +- 23 files changed, 819 insertions(+), 27 deletions(-) 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 94d7c0b81b..b2e404c141 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -567,10 +567,12 @@ swipe action
Accept as member + Als Mitglied übernehmen alert action Accept as observer + Als Beobachter übernehmen alert action @@ -596,6 +598,7 @@ swipe action Accept member + Mitglied übernehmen alert title @@ -1596,10 +1599,12 @@ set passcode view Chat with admins + Chat mit Administratoren chat toolbar Chat with member + Chat mit einem Mitglied No comment provided by engineer. @@ -1609,6 +1614,7 @@ set passcode view Chats with members + Chats mit Mitgliedern No comment provided by engineer. @@ -2428,6 +2434,7 @@ swipe action Delete chat with member? + Chat mit dem Mitglied löschen? alert title @@ -3173,6 +3180,7 @@ chat item action Error accepting member + Fehler beim Übernehmen des Mitglieds alert title @@ -3272,6 +3280,7 @@ chat item action Error deleting chat with member + Fehler beim Löschen des Chats mit dem Mitglied alert title @@ -4768,6 +4777,7 @@ Das ist Ihr Link für die Gruppe %@! Member admission + Aufnahme von Mitgliedern No comment provided by engineer. @@ -4807,6 +4817,7 @@ Das ist Ihr Link für die Gruppe %@! Member will join the group, accept member? + Ein Mitglied wird der Gruppe beitreten. Übernehmen? alert message @@ -5231,6 +5242,7 @@ Das ist Ihr Link für die Gruppe %@! New member wants to join the group. + Ein neues Mitglied will der Gruppe beitreten. rcv group event chat item @@ -5275,6 +5287,7 @@ Das ist Ihr Link für die Gruppe %@! No chats with members + Keine Chats mit Mitgliedern No comment provided by engineer. @@ -5884,6 +5897,7 @@ Fehler: %@ Please wait for group moderators to review your request to join the group. + Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können. snd group event chat item @@ -6341,6 +6355,7 @@ swipe action Reject member? + Mitglied ablehnen? alert title @@ -6455,6 +6470,7 @@ swipe action Report sent to moderators + Meldung wurde an die Moderatoren gesendet alert title @@ -6574,10 +6590,12 @@ swipe action Review members + Überprüfung der Mitglieder admission stage Review members before admitting ("knocking"). + Überprüfung der Mitglieder vor der Aufnahme ("Anklopfen"). admission stage description @@ -6638,6 +6656,7 @@ chat item action Save admission settings? + Speichern der Aufnahme-Einstellungen? alert title @@ -7157,6 +7176,7 @@ chat item action Set member admission + Aufnahme von Mitgliedern festlegen No comment provided by engineer. @@ -8923,6 +8943,7 @@ Verbindungsanfrage wiederholen? You can view your reports in Chat with admins. + Sie können Ihre Meldungen im Chat mit den Administratoren sehen. alert message @@ -9229,6 +9250,7 @@ Verbindungsanfrage wiederholen? accepted %@ + %@ übernommen rcv group event chat item @@ -9243,6 +9265,7 @@ Verbindungsanfrage wiederholen? accepted you + hat Sie übernommen rcv group event chat item @@ -9267,6 +9290,7 @@ Verbindungsanfrage wiederholen? all + alle member criteria value @@ -9357,6 +9381,7 @@ marked deleted chat item preview text can't send messages + Es können keine Nachrichten gesendet werden No comment provided by engineer. @@ -9466,10 +9491,12 @@ marked deleted chat item preview text contact deleted + Kontakt gelöscht No comment provided by engineer. contact disabled + Kontakt deaktiviert No comment provided by engineer. @@ -9484,6 +9511,7 @@ marked deleted chat item preview text contact not ready + Kontakt nicht bereit No comment provided by engineer. @@ -9659,6 +9687,7 @@ pref value group is deleted + Gruppe wird gelöscht No comment provided by engineer. @@ -9788,6 +9817,7 @@ pref value member has old version + Das Mitglied hat eine alte App-Version No comment provided by engineer. @@ -9857,6 +9887,7 @@ pref value not synchronized + Nicht synchronisiert No comment provided by engineer. @@ -9924,6 +9955,7 @@ time to disappear pending review + Ausstehende Überprüfung No comment provided by engineer. @@ -9968,6 +10000,7 @@ time to disappear removed from group + Von der Gruppe entfernt No comment provided by engineer. @@ -9982,6 +10015,7 @@ time to disappear request to join rejected + Beitrittsanfrage abgelehnt No comment provided by engineer. @@ -9991,10 +10025,12 @@ time to disappear review + Überprüfung No comment provided by engineer. reviewed by admins + Von Administratoren überprüft No comment provided by engineer. @@ -10188,6 +10224,7 @@ Zuletzt empfangene Nachricht: %2$@ you accepted this member + Sie haben dieses Mitglied übernommen snd group event chat item 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 d672a0da4f..d3c2a139cc 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -567,10 +567,12 @@ swipe action Accept as member + Accetta come membro alert action Accept as observer + Accetta come osservatore alert action @@ -596,6 +598,7 @@ swipe action Accept member + Accetta membro alert title @@ -1596,10 +1599,12 @@ set passcode view Chat with admins + Chat con amministratori chat toolbar Chat with member + Chatta con il membro No comment provided by engineer. @@ -1609,6 +1614,7 @@ set passcode view Chats with members + Chat con membri No comment provided by engineer. @@ -2428,6 +2434,7 @@ swipe action Delete chat with member? + Eliminare la chat con il membro? alert title @@ -3173,6 +3180,7 @@ chat item action Error accepting member + Errore di accettazione del membro alert title @@ -3272,6 +3280,7 @@ chat item action Error deleting chat with member + Errore di eliminazione della chat con il membro alert title @@ -4768,6 +4777,7 @@ Questo è il tuo link per il gruppo %@! Member admission + Ammissione del membro No comment provided by engineer. @@ -4807,6 +4817,7 @@ Questo è il tuo link per il gruppo %@! Member will join the group, accept member? + Il membro entrerà nel gruppo, accettarlo? alert message @@ -5231,6 +5242,7 @@ Questo è il tuo link per il gruppo %@! New member wants to join the group. + Un nuovo membro vuole entrare nel gruppo. rcv group event chat item @@ -5275,6 +5287,7 @@ Questo è il tuo link per il gruppo %@! No chats with members + Nessuna chat con membri No comment provided by engineer. @@ -5884,6 +5897,7 @@ Errore: %@ Please wait for group moderators to review your request to join the group. + Attendi che i moderatori del gruppo revisionino la tua richiesta di entrare nel gruppo. snd group event chat item @@ -6341,6 +6355,7 @@ swipe action Reject member? + Rifiutare il membro? alert title @@ -6455,6 +6470,7 @@ swipe action Report sent to moderators + Segnalazione inviata ai moderatori alert title @@ -6574,10 +6590,12 @@ swipe action Review members + Revisiona i membri admission stage Review members before admitting ("knocking"). + Revisiona i membri prima di ammetterli ("bussare"). admission stage description @@ -6638,6 +6656,7 @@ chat item action Save admission settings? + Salvare le impostazioni di ammissione? alert title @@ -7157,6 +7176,7 @@ chat item action Set member admission + Imposta l'ammissione del membro No comment provided by engineer. @@ -8923,6 +8943,7 @@ Ripetere la richiesta di ingresso? You can view your reports in Chat with admins. + Puoi vedere le tue segnalazioni nella chat con gli amministratori. alert message @@ -9229,6 +9250,7 @@ Ripetere la richiesta di connessione? accepted %@ + %@ accettato rcv group event chat item @@ -9243,6 +9265,7 @@ Ripetere la richiesta di connessione? accepted you + ti ha accettato/a rcv group event chat item @@ -9267,6 +9290,7 @@ Ripetere la richiesta di connessione? all + tutti member criteria value @@ -9357,6 +9381,7 @@ marked deleted chat item preview text can't send messages + impossibile inviare messaggi No comment provided by engineer. @@ -9466,10 +9491,12 @@ marked deleted chat item preview text contact deleted + contatto eliminato No comment provided by engineer. contact disabled + contatto disattivato No comment provided by engineer. @@ -9484,6 +9511,7 @@ marked deleted chat item preview text contact not ready + contatto non pronto No comment provided by engineer. @@ -9659,6 +9687,7 @@ pref value group is deleted + il gruppo è eliminato No comment provided by engineer. @@ -9788,6 +9817,7 @@ pref value member has old version + il membro ha una versione vecchia No comment provided by engineer. @@ -9857,6 +9887,7 @@ pref value not synchronized + non sincronizzato No comment provided by engineer. @@ -9924,6 +9955,7 @@ time to disappear pending review + in attesa di revisione No comment provided by engineer. @@ -9968,6 +10000,7 @@ time to disappear removed from group + rimosso dal gruppo No comment provided by engineer. @@ -9982,6 +10015,7 @@ time to disappear request to join rejected + richiesta di entrare rifiutata No comment provided by engineer. @@ -9991,10 +10025,12 @@ time to disappear review + revisiona No comment provided by engineer. reviewed by admins + revisionato dagli amministratori No comment provided by engineer. @@ -10188,6 +10224,7 @@ ultimo msg ricevuto: %2$@ you accepted this member + hai accettato questo membro snd group event chat item 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 651d9a7063..2ec1130718 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -167,7 +167,7 @@ %d hours - %d ч. + %d час. time interval @@ -567,10 +567,12 @@ swipe action Accept as member + Принять в группу alert action Accept as observer + Принять как читателя alert action @@ -596,6 +598,7 @@ swipe action Accept member + Принять члена alert title @@ -810,6 +813,7 @@ swipe action All servers + Все серверы No comment provided by engineer. @@ -1595,10 +1599,12 @@ set passcode view Chat with admins + Чат с админами chat toolbar Chat with member + Чат с членом группы No comment provided by engineer. @@ -1608,6 +1614,7 @@ set passcode view Chats with members + Чаты с членами группы No comment provided by engineer. @@ -2427,6 +2434,7 @@ swipe action Delete chat with member? + Удалить чат с членом группы? alert title @@ -2716,6 +2724,7 @@ swipe action Direct messages between members are prohibited. + Прямые сообщения между членами запрещены. No comment provided by engineer. @@ -2820,6 +2829,7 @@ swipe action Do not send history to new members. + Не отправлять историю новым членам. No comment provided by engineer. @@ -2945,6 +2955,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных. No comment provided by engineer. @@ -3169,10 +3180,12 @@ chat item action Error accepting member + Ошибка вступления члена группы alert title Error adding member(s) + Ошибка при добавлении членов группы No comment provided by engineer. @@ -3237,6 +3250,7 @@ chat item action Error creating member contact + Ошибка при создании контакта No comment provided by engineer. @@ -3266,6 +3280,7 @@ chat item action Error deleting chat with member + Ошибка при удалении чата с членом группы alert title @@ -3375,6 +3390,7 @@ chat item action Error removing member + Ошибка при удалении члена группы alert title @@ -3439,6 +3455,7 @@ chat item action Error sending member contact invitation + Ошибка при отправке приглашения члену No comment provided by engineer. @@ -3774,6 +3791,7 @@ snd error text Fix not supported by group member + Починка не поддерживается членом группы. No comment provided by engineer. @@ -3907,6 +3925,7 @@ Error: %2$@ Fully decentralized – visible only to members. + Группа полностью децентрализована – она видна только членам. No comment provided by engineer. @@ -4016,6 +4035,7 @@ Error: %2$@ Group profile is stored on members' devices, not on the servers. + Профиль группы хранится на устройствах членов, а не на серверах. No comment provided by engineer. @@ -4025,6 +4045,7 @@ Error: %2$@ Group will be deleted for all members - this cannot be undone! + Группа будет удалена для всех членов - это действие нельзя отменить! No comment provided by engineer. @@ -4089,6 +4110,7 @@ Error: %2$@ History is not sent to new members. + История не отправляется новым членам. No comment provided by engineer. @@ -4436,6 +4458,7 @@ More improvements are coming soon! Invite members + Пригласить членов группы No comment provided by engineer. @@ -4748,14 +4771,17 @@ This is your link for group %@! Member + Член группы No comment provided by engineer. Member admission + Приём членов в группу No comment provided by engineer. Member inactive + Член неактивен item status text @@ -4770,58 +4796,72 @@ This is your link for group %@! Member role will be changed to "%@". All group members will be notified. + Роль члена будет изменена на "%@". Все члены группы получат уведомление. No comment provided by engineer. Member role will be changed to "%@". The member will receive a new invitation. + Роль члена будет изменена на "%@". Будет отправлено новое приглашение. No comment provided by engineer. Member will be removed from chat - this cannot be undone! + Член будет удален из разговора - это действие нельзя отменить! No comment provided by engineer. Member will be removed from group - this cannot be undone! + Член группы будет удален - это действие нельзя отменить! No comment provided by engineer. Member will join the group, accept member? + Участник хочет присоединиться к группе. Принять? alert message Members can add message reactions. + Члены могут добавлять реакции на сообщения. No comment provided by engineer. Members can irreversibly delete sent messages. (24 hours) + Члены могут необратимо удалять отправленные сообщения. (24 часа) No comment provided by engineer. Members can report messsages to moderators. + Члены группы могут пожаловаться модераторам. No comment provided by engineer. Members can send SimpleX links. + Члены могут отправлять ссылки SimpleX. No comment provided by engineer. Members can send direct messages. + Члены могут посылать прямые сообщения. No comment provided by engineer. Members can send disappearing messages. + Члены могут посылать исчезающие сообщения. No comment provided by engineer. Members can send files and media. + Члены могут слать файлы и медиа. No comment provided by engineer. Members can send voice messages. + Члены могут отправлять голосовые сообщения. No comment provided by engineer. Mention members 👋 + Упоминайте участников 👋 No comment provided by engineer. @@ -4856,6 +4896,7 @@ This is your link for group %@! Message may be delivered later if member becomes active. + Сообщение может быть доставлено позже, если член группы станет активным. item status description @@ -5195,10 +5236,12 @@ This is your link for group %@! New member role + Роль члена группы No comment provided by engineer. New member wants to join the group. + Новый участник хочет присоединиться к группе. rcv group event chat item @@ -5243,6 +5286,7 @@ This is your link for group %@! No chats with members + Нет чатов с членами группы No comment provided by engineer. @@ -5419,6 +5463,9 @@ This is your link for group %@! Now admins can: - delete members' messages. - disable members ("observer" role) + Теперь админы могут: +- удалять сообщения членов. +- приостанавливать членов (роль наблюдатель) No comment provided by engineer. @@ -5593,6 +5640,7 @@ Requires compatible VPN. Open link? + Открыть ссылку? alert title @@ -5709,6 +5757,7 @@ Requires compatible VPN. Past member %@ + Бывший член %@ past/unknown group member @@ -5847,6 +5896,7 @@ Error: %@ Please wait for group moderators to review your request to join the group. + Пожалуйста, подождите, пока модераторы группы рассмотрят ваш запрос на вступление. snd group event chat item @@ -6021,6 +6071,7 @@ Error: %@ Prohibit sending direct messages to members. + Запретить посылать прямые сообщения членам группы. No comment provided by engineer. @@ -6303,6 +6354,7 @@ swipe action Reject member? + Отклонить участника? alert title @@ -6332,10 +6384,12 @@ swipe action Remove member + Удалить члена группы No comment provided by engineer. Remove member? + Удалить члена группы? No comment provided by engineer. @@ -6415,6 +6469,7 @@ swipe action Report sent to moderators + Жалоба отправлена модераторам alert title @@ -6534,10 +6589,12 @@ swipe action Review members + Одобрять членов admission stage Review members before admitting ("knocking"). + Одобрять членов для вступления в группу. admission stage description @@ -6598,6 +6655,7 @@ chat item action Save admission settings? + Сохранить настройки вступления? alert title @@ -6607,6 +6665,7 @@ chat item action Save and notify group members + Сохранить и уведомить членов группы No comment provided by engineer. @@ -6891,6 +6950,7 @@ chat item action Send up to 100 last messages to new members. + Отправить до 100 последних сообщений новым членам. No comment provided by engineer. @@ -7115,6 +7175,7 @@ chat item action Set member admission + Приём членов в группу No comment provided by engineer. @@ -7139,6 +7200,7 @@ chat item action Set the message shown to new members! + Установить сообщение для новых членов группы! No comment provided by engineer. @@ -7229,6 +7291,7 @@ chat item action Short link + Короткая ссылка No comment provided by engineer. @@ -7333,6 +7396,7 @@ chat item action SimpleX channel link + SimpleX ссылка канала simplex link type @@ -7777,18 +7841,22 @@ It can happen because of some bug or when the connection is compromised. The message will be deleted for all members. + Сообщение будет удалено для всех членов группы. No comment provided by engineer. The message will be marked as moderated for all members. + Сообщение будет помечено как удаленное для всех членов группы. No comment provided by engineer. The messages will be deleted for all members. + Сообщения будут удалены для всех членов группы. No comment provided by engineer. The messages will be marked as moderated for all members. + Сообщения будут помечены как удаленные для всех членов группы. No comment provided by engineer. @@ -7898,6 +7966,7 @@ It can happen because of some bug or when the connection is compromised. This group has over %lld members, delivery receipts are not sent. + В этой группе более %lld членов, отчёты о доставке не отправляются. No comment provided by engineer. @@ -7917,6 +7986,7 @@ It can happen because of some bug or when the connection is compromised. This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку. No comment provided by engineer. @@ -8108,14 +8178,17 @@ You will be prompted to complete authentication before this feature is enabled.< Unblock member + Разблокировать члена группы No comment provided by engineer. Unblock member for all? + Разблокировать члена для всех? No comment provided by engineer. Unblock member? + Разблокировать члена группы? No comment provided by engineer. @@ -8217,10 +8290,12 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link + Ссылка не поддерживается No comment provided by engineer. Up to 100 last messages are sent to new members. + До 100 последних сообщений отправляются новым членам. No comment provided by engineer. @@ -8315,6 +8390,7 @@ To connect, please ask your contact to create another connection link and check Use TCP port 443 for preset servers only. + Использовать TCP-порт 443 только для серверов по умолчанию. No comment provided by engineer. @@ -8384,6 +8460,7 @@ To connect, please ask your contact to create another connection link and check Use short links (BETA) + Короткие ссылки (БЕТА) No comment provided by engineer. @@ -8830,6 +8907,7 @@ Repeat join request? You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it. + Вы можете поделиться ссылкой или QR кодом - через них можно присоединиться к группе. Вы сможете удалить ссылку, сохранив членов группы, которые через нее соединились. No comment provided by engineer. @@ -8864,6 +8942,7 @@ Repeat join request? You can view your reports in Chat with admins. + Вы можете найти Ваши жалобы в Чате с админами. alert message @@ -8910,6 +8989,7 @@ Repeat connection request? You joined this group. Connecting to inviting group member. + Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы. No comment provided by engineer. @@ -8979,6 +9059,7 @@ Repeat connection request? You will connect to all group members. + Вы соединитесь со всеми членами группы. No comment provided by engineer. @@ -9168,6 +9249,7 @@ Repeat connection request? accepted %@ + принят %@ rcv group event chat item @@ -9182,6 +9264,7 @@ Repeat connection request? accepted you + Вы приняты rcv group event chat item @@ -9206,10 +9289,12 @@ Repeat connection request? all + все member criteria value all members + все члены feature role @@ -9295,6 +9380,7 @@ marked deleted chat item preview text can't send messages + нельзя отправлять No comment provided by engineer. @@ -9404,10 +9490,12 @@ marked deleted chat item preview text contact deleted + контакт удален No comment provided by engineer. contact disabled + контакт выключен No comment provided by engineer. @@ -9422,6 +9510,7 @@ marked deleted chat item preview text contact not ready + контакт не готов No comment provided by engineer. @@ -9597,6 +9686,7 @@ pref value group is deleted + группа удалена No comment provided by engineer. @@ -9711,10 +9801,12 @@ pref value member + член группы member role member %1$@ changed to %2$@ + член %1$@ изменился на %2$@ profile update event chat item @@ -9724,6 +9816,7 @@ pref value member has old version + член имеет старую версию No comment provided by engineer. @@ -9793,6 +9886,7 @@ pref value not synchronized + не синхронизирован No comment provided by engineer. @@ -9860,6 +9954,7 @@ time to disappear pending review + ожидает одобрения No comment provided by engineer. @@ -9904,6 +9999,7 @@ time to disappear removed from group + удален из группы No comment provided by engineer. @@ -9918,6 +10014,7 @@ time to disappear request to join rejected + запрос на вступление отклонён No comment provided by engineer. @@ -9927,10 +10024,12 @@ time to disappear review + рассмотрение No comment provided by engineer. reviewed by admins + одобрен админами No comment provided by engineer. @@ -10124,6 +10223,7 @@ last received msg: %2$@ you accepted this member + Вы приняли этого члена snd group event chat item @@ -10269,6 +10369,7 @@ last received msg: %2$@ From %d chat(s) + Из %d чатов notification body diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index 7205b37e7f..cf082a166d 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* notification body */ "%d new events" = "%d новых сообщений"; +/* notification body */ +"From %d chat(s)" = "Из %d чатов"; + /* notification body */ "From: %@" = "От: %@"; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 47f1390e0b..1f4ff88f78 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -3158,7 +3158,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Острани член"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Острани член?"; /* No comment provided by engineer. */ diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index cc19af7a74..a3a6ca8215 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -2478,7 +2478,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Odstranit člena"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Odebrat člena?"; /* No comment provided by engineer. */ diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 71cfaa841b..4be4ad96ba 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -345,6 +345,12 @@ accept incoming call via notification swipe action */ "Accept" = "Annehmen"; +/* alert action */ +"Accept as member" = "Als Mitglied übernehmen"; + +/* alert action */ +"Accept as observer" = "Als Beobachter übernehmen"; + /* No comment provided by engineer. */ "Accept conditions" = "Nutzungsbedingungen akzeptieren"; @@ -358,6 +364,12 @@ swipe action */ swipe action */ "Accept incognito" = "Inkognito akzeptieren"; +/* alert title */ +"Accept member" = "Mitglied übernehmen"; + +/* rcv group event chat item */ +"accepted %@" = "%@ übernommen"; + /* call status */ "accepted call" = "Anruf angenommen"; @@ -367,6 +379,9 @@ swipe action */ /* chat list item title */ "accepted invitation" = "Einladung angenommen"; +/* rcv group event chat item */ +"accepted you" = "hat Sie übernommen"; + /* No comment provided by engineer. */ "Acknowledged" = "Bestätigt"; @@ -463,6 +478,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "Verschlüsselung zustimmen…"; +/* member criteria value */ +"all" = "alle"; + /* No comment provided by engineer. */ "All" = "Alle"; @@ -905,6 +923,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Mitglied kann nicht benachrichtigt werden"; +/* No comment provided by engineer. */ +"can't send messages" = "Es können keine Nachrichten gesendet werden"; + /* alert action alert button */ "Cancel" = "Abbrechen"; @@ -1042,9 +1063,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Der Chat wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden!"; +/* chat toolbar */ +"Chat with admins" = "Chat mit Administratoren"; + +/* No comment provided by engineer. */ +"Chat with member" = "Chat mit einem Mitglied"; + /* No comment provided by engineer. */ "Chats" = "Chats"; +/* No comment provided by engineer. */ +"Chats with members" = "Chats mit Mitgliedern"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Alle 20min Nachrichten überprüfen."; @@ -1333,9 +1363,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "Der Kontakt ist bereits vorhanden"; +/* No comment provided by engineer. */ +"contact deleted" = "Kontakt gelöscht"; + /* No comment provided by engineer. */ "Contact deleted!" = "Kontakt gelöscht!"; +/* No comment provided by engineer. */ +"contact disabled" = "Kontakt deaktiviert"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "Kontakt nutzt E2E-Verschlüsselung"; @@ -1354,6 +1390,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "Kontaktname"; +/* No comment provided by engineer. */ +"contact not ready" = "Kontakt nicht bereit"; + /* No comment provided by engineer. */ "Contact preferences" = "Kontakt-Präferenzen"; @@ -1602,6 +1641,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat profile?" = "Chat-Profil löschen?"; +/* alert title */ +"Delete chat with member?" = "Chat mit dem Mitglied löschen?"; + /* No comment provided by engineer. */ "Delete chat?" = "Chat löschen?"; @@ -2116,6 +2158,9 @@ chat item action */ /* No comment provided by engineer. */ "Error accepting contact request" = "Fehler beim Annehmen der Kontaktanfrage"; +/* alert title */ +"Error accepting member" = "Fehler beim Übernehmen des Mitglieds"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Fehler beim Hinzufügen von Mitgliedern"; @@ -2173,6 +2218,9 @@ chat item action */ /* No comment provided by engineer. */ "Error deleting chat database" = "Fehler beim Löschen der Chat-Datenbank"; +/* alert title */ +"Error deleting chat with member" = "Fehler beim Löschen des Chats mit dem Mitglied"; + /* No comment provided by engineer. */ "Error deleting chat!" = "Fehler beim Löschen des Chats!"; @@ -2610,6 +2658,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Die Gruppeneinladung ist nicht mehr gültig, da sie vom Absender entfernt wurde."; +/* No comment provided by engineer. */ +"group is deleted" = "Gruppe wird gelöscht"; + /* No comment provided by engineer. */ "Group link" = "Gruppen-Link"; @@ -3138,9 +3189,15 @@ snd error text */ /* profile update event chat item */ "member %@ changed to %@" = "Der Mitgliedsname von %1$@ wurde auf %2$@ geändert"; +/* No comment provided by engineer. */ +"Member admission" = "Aufnahme von Mitgliedern"; + /* rcv group event chat item */ "member connected" = "ist der Gruppe beigetreten"; +/* No comment provided by engineer. */ +"member has old version" = "Das Mitglied hat eine alte App-Version"; + /* item status text */ "Member inactive" = "Mitglied inaktiv"; @@ -3162,6 +3219,9 @@ snd error text */ /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden!"; +/* alert message */ +"Member will join the group, accept member?" = "Ein Mitglied wird der Gruppe beitreten. Übernehmen?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "Gruppenmitglieder können eine Reaktion auf Nachrichten geben."; @@ -3432,6 +3492,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "Neue Mitgliedsrolle"; +/* rcv group event chat item */ +"New member wants to join the group." = "Ein neues Mitglied will der Gruppe beitreten."; + /* notification */ "new message" = "Neue Nachricht"; @@ -3471,6 +3534,9 @@ snd error text */ /* No comment provided by engineer. */ "No chats in list %@" = "Keine Chats in der Liste %@"; +/* No comment provided by engineer. */ +"No chats with members" = "Keine Chats mit Mitgliedern"; + /* No comment provided by engineer. */ "No contacts selected" = "Keine Kontakte ausgewählt"; @@ -3555,6 +3621,9 @@ snd error text */ /* No comment provided by engineer. */ "Not compatible!" = "Nicht kompatibel!"; +/* No comment provided by engineer. */ +"not synchronized" = "Nicht synchronisiert"; + /* No comment provided by engineer. */ "Notes" = "Anmerkungen"; @@ -3802,6 +3871,9 @@ alert button */ /* No comment provided by engineer. */ "pending approval" = "ausstehende Genehmigung"; +/* No comment provided by engineer. */ +"pending review" = "Ausstehende Überprüfung"; + /* No comment provided by engineer. */ "Periodic" = "Periodisch"; @@ -3871,6 +3943,9 @@ alert button */ /* token info */ "Please try to disable and re-enable notfications." = "Bitte versuchen Sie, die Benachrichtigungen zu deaktivieren und wieder zu aktivieren."; +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können."; + /* token info */ "Please wait for token activation to complete." = "Bitte warten Sie, bis die Token-Aktivierung abgeschlossen ist."; @@ -4151,6 +4226,9 @@ swipe action */ /* No comment provided by engineer. */ "Reject contact request" = "Kontaktanfrage ablehnen"; +/* alert title */ +"Reject member?" = "Mitglied ablehnen?"; + /* No comment provided by engineer. */ "rejected" = "abgelehnt"; @@ -4175,7 +4253,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Mitglied entfernen"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Das Mitglied entfernen?"; /* No comment provided by engineer. */ @@ -4190,6 +4268,9 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "Die Kontaktadresse wurde entfernt"; +/* No comment provided by engineer. */ +"removed from group" = "Von der Gruppe entfernt"; + /* profile update event chat item */ "removed profile picture" = "Das Profil-Bild wurde entfernt"; @@ -4238,6 +4319,9 @@ swipe action */ /* No comment provided by engineer. */ "Report reason?" = "Grund der Meldung?"; +/* alert title */ +"Report sent to moderators" = "Meldung wurde an die Moderatoren gesendet"; + /* report reason */ "Report spam: only group moderators will see it." = "Spam melden: Nur Gruppenmoderatoren werden es sehen."; @@ -4253,6 +4337,9 @@ swipe action */ /* No comment provided by engineer. */ "Reports" = "Meldungen"; +/* No comment provided by engineer. */ +"request to join rejected" = "Beitrittsanfrage abgelehnt"; + /* chat list item title */ "requested to connect" = "Zur Verbindung aufgefordert"; @@ -4307,9 +4394,21 @@ swipe action */ /* chat item action */ "Reveal" = "Aufdecken"; +/* No comment provided by engineer. */ +"review" = "Überprüfung"; + /* No comment provided by engineer. */ "Review conditions" = "Nutzungsbedingungen einsehen"; +/* admission stage */ +"Review members" = "Überprüfung der Mitglieder"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Überprüfung der Mitglieder vor der Aufnahme (\"Anklopfen\")."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "Von Administratoren überprüft"; + /* No comment provided by engineer. */ "Revoke" = "Widerrufen"; @@ -4338,6 +4437,9 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "Speichern (und Kontakte benachrichtigen)"; +/* alert title */ +"Save admission settings?" = "Speichern der Aufnahme-Einstellungen?"; + /* alert button */ "Save and notify contact" = "Speichern und Kontakt benachrichtigen"; @@ -4674,6 +4776,9 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Anstelle der System-Authentifizierung festlegen."; +/* No comment provided by engineer. */ +"Set member admission" = "Aufnahme von Mitgliedern festlegen"; + /* No comment provided by engineer. */ "Set message expiration in chats." = "Verfallsdatum von Nachrichten in Chats festlegen."; @@ -5714,6 +5819,9 @@ report reason */ /* No comment provided by engineer. */ "You accepted connection" = "Sie haben die Verbindung akzeptiert"; +/* snd group event chat item */ +"you accepted this member" = "Sie haben dieses Mitglied übernommen"; + /* No comment provided by engineer. */ "You allow" = "Sie erlauben"; @@ -5825,6 +5933,9 @@ report reason */ /* alert message */ "You can view invitation link again in connection details." = "Den Einladungslink können Sie in den Details der Verbindung nochmals sehen."; +/* alert message */ +"You can view your reports in Chat with admins." = "Sie können Ihre Meldungen im Chat mit den Administratoren sehen."; + /* No comment provided by engineer. */ "You can't send messages!" = "Sie können keine Nachrichten versenden!"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 9ae294f6ef..a8782c401f 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -4172,7 +4172,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Expulsar miembro"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "¿Expulsar miembro?"; /* No comment provided by engineer. */ diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 3aa3bdbc42..8e489f7a71 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -2409,7 +2409,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Poista jäsen"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Poista jäsen?"; /* No comment provided by engineer. */ diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 55a76aaa37..9b570a5ae9 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -4016,7 +4016,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Retirer le membre"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Retirer ce membre ?"; /* No comment provided by engineer. */ diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 996484aea0..f36b35efc2 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -345,6 +345,12 @@ accept incoming call via notification swipe action */ "Accept" = "Accetta"; +/* alert action */ +"Accept as member" = "Accetta come membro"; + +/* alert action */ +"Accept as observer" = "Accetta come osservatore"; + /* No comment provided by engineer. */ "Accept conditions" = "Accetta le condizioni"; @@ -358,6 +364,12 @@ swipe action */ swipe action */ "Accept incognito" = "Accetta in incognito"; +/* alert title */ +"Accept member" = "Accetta membro"; + +/* rcv group event chat item */ +"accepted %@" = "%@ accettato"; + /* call status */ "accepted call" = "chiamata accettata"; @@ -367,6 +379,9 @@ swipe action */ /* chat list item title */ "accepted invitation" = "invito accettato"; +/* rcv group event chat item */ +"accepted you" = "ti ha accettato/a"; + /* No comment provided by engineer. */ "Acknowledged" = "Riconosciuto"; @@ -463,6 +478,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "concordando la crittografia…"; +/* member criteria value */ +"all" = "tutti"; + /* No comment provided by engineer. */ "All" = "Tutte"; @@ -905,6 +923,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Impossibile inviare un messaggio al membro"; +/* No comment provided by engineer. */ +"can't send messages" = "impossibile inviare messaggi"; + /* alert action alert button */ "Cancel" = "Annulla"; @@ -1042,9 +1063,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "La chat verrà eliminata solo per te, non è reversibile!"; +/* chat toolbar */ +"Chat with admins" = "Chat con amministratori"; + +/* No comment provided by engineer. */ +"Chat with member" = "Chatta con il membro"; + /* No comment provided by engineer. */ "Chats" = "Chat"; +/* No comment provided by engineer. */ +"Chats with members" = "Chat con membri"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Controlla i messaggi ogni 20 min."; @@ -1333,9 +1363,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "Il contatto esiste già"; +/* No comment provided by engineer. */ +"contact deleted" = "contatto eliminato"; + /* No comment provided by engineer. */ "Contact deleted!" = "Contatto eliminato!"; +/* No comment provided by engineer. */ +"contact disabled" = "contatto disattivato"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "il contatto ha la crittografia e2e"; @@ -1354,6 +1390,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "Nome del contatto"; +/* No comment provided by engineer. */ +"contact not ready" = "contatto non pronto"; + /* No comment provided by engineer. */ "Contact preferences" = "Preferenze del contatto"; @@ -1602,6 +1641,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat profile?" = "Eliminare il profilo di chat?"; +/* alert title */ +"Delete chat with member?" = "Eliminare la chat con il membro?"; + /* No comment provided by engineer. */ "Delete chat?" = "Eliminare la chat?"; @@ -2116,6 +2158,9 @@ chat item action */ /* No comment provided by engineer. */ "Error accepting contact request" = "Errore nell'accettazione della richiesta di contatto"; +/* alert title */ +"Error accepting member" = "Errore di accettazione del membro"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Errore di aggiunta membro/i"; @@ -2173,6 +2218,9 @@ chat item action */ /* No comment provided by engineer. */ "Error deleting chat database" = "Errore nell'eliminazione del database della chat"; +/* alert title */ +"Error deleting chat with member" = "Errore di eliminazione della chat con il membro"; + /* No comment provided by engineer. */ "Error deleting chat!" = "Errore nell'eliminazione della chat!"; @@ -2610,6 +2658,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "L'invito al gruppo non è più valido, è stato rimosso dal mittente."; +/* No comment provided by engineer. */ +"group is deleted" = "il gruppo è eliminato"; + /* No comment provided by engineer. */ "Group link" = "Link del gruppo"; @@ -3138,9 +3189,15 @@ snd error text */ /* profile update event chat item */ "member %@ changed to %@" = "il membro %1$@ è diventato %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "Ammissione del membro"; + /* rcv group event chat item */ "member connected" = "si è connesso/a"; +/* No comment provided by engineer. */ +"member has old version" = "il membro ha una versione vecchia"; + /* item status text */ "Member inactive" = "Membro inattivo"; @@ -3162,6 +3219,9 @@ snd error text */ /* No comment provided by engineer. */ "Member will be removed from group - this cannot be undone!" = "Il membro verrà rimosso dal gruppo, non è reversibile!"; +/* alert message */ +"Member will join the group, accept member?" = "Il membro entrerà nel gruppo, accettarlo?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "I membri del gruppo possono aggiungere reazioni ai messaggi."; @@ -3432,6 +3492,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "Nuovo ruolo del membro"; +/* rcv group event chat item */ +"New member wants to join the group." = "Un nuovo membro vuole entrare nel gruppo."; + /* notification */ "new message" = "messaggio nuovo"; @@ -3471,6 +3534,9 @@ snd error text */ /* No comment provided by engineer. */ "No chats in list %@" = "Nessuna chat nell'elenco %@"; +/* No comment provided by engineer. */ +"No chats with members" = "Nessuna chat con membri"; + /* No comment provided by engineer. */ "No contacts selected" = "Nessun contatto selezionato"; @@ -3555,6 +3621,9 @@ snd error text */ /* No comment provided by engineer. */ "Not compatible!" = "Non compatibile!"; +/* No comment provided by engineer. */ +"not synchronized" = "non sincronizzato"; + /* No comment provided by engineer. */ "Notes" = "Note"; @@ -3802,6 +3871,9 @@ alert button */ /* No comment provided by engineer. */ "pending approval" = "in attesa di approvazione"; +/* No comment provided by engineer. */ +"pending review" = "in attesa di revisione"; + /* No comment provided by engineer. */ "Periodic" = "Periodicamente"; @@ -3871,6 +3943,9 @@ alert button */ /* token info */ "Please try to disable and re-enable notfications." = "Prova a disattivare e riattivare le notifiche."; +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Attendi che i moderatori del gruppo revisionino la tua richiesta di entrare nel gruppo."; + /* token info */ "Please wait for token activation to complete." = "Attendi il completamento dell'attivazione del token."; @@ -4151,6 +4226,9 @@ swipe action */ /* No comment provided by engineer. */ "Reject contact request" = "Rifiuta la richiesta di contatto"; +/* alert title */ +"Reject member?" = "Rifiutare il membro?"; + /* No comment provided by engineer. */ "rejected" = "rifiutato"; @@ -4175,7 +4253,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Rimuovi membro"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Rimuovere il membro?"; /* No comment provided by engineer. */ @@ -4190,6 +4268,9 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "indirizzo di contatto rimosso"; +/* No comment provided by engineer. */ +"removed from group" = "rimosso dal gruppo"; + /* profile update event chat item */ "removed profile picture" = "immagine del profilo rimossa"; @@ -4238,6 +4319,9 @@ swipe action */ /* No comment provided by engineer. */ "Report reason?" = "Motivo della segnalazione?"; +/* alert title */ +"Report sent to moderators" = "Segnalazione inviata ai moderatori"; + /* report reason */ "Report spam: only group moderators will see it." = "Segnala spam: solo i moderatori del gruppo lo vedranno."; @@ -4253,6 +4337,9 @@ swipe action */ /* No comment provided by engineer. */ "Reports" = "Segnalazioni"; +/* No comment provided by engineer. */ +"request to join rejected" = "richiesta di entrare rifiutata"; + /* chat list item title */ "requested to connect" = "richiesto di connettersi"; @@ -4307,9 +4394,21 @@ swipe action */ /* chat item action */ "Reveal" = "Rivela"; +/* No comment provided by engineer. */ +"review" = "revisiona"; + /* No comment provided by engineer. */ "Review conditions" = "Leggi le condizioni"; +/* admission stage */ +"Review members" = "Revisiona i membri"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Revisiona i membri prima di ammetterli (\"bussare\")."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "revisionato dagli amministratori"; + /* No comment provided by engineer. */ "Revoke" = "Revoca"; @@ -4338,6 +4437,9 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "Salva (e avvisa i contatti)"; +/* alert title */ +"Save admission settings?" = "Salvare le impostazioni di ammissione?"; + /* alert button */ "Save and notify contact" = "Salva e avvisa il contatto"; @@ -4674,6 +4776,9 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Impostalo al posto dell'autenticazione di sistema."; +/* No comment provided by engineer. */ +"Set member admission" = "Imposta l'ammissione del membro"; + /* No comment provided by engineer. */ "Set message expiration in chats." = "Imposta la scadenza dei messaggi nelle chat."; @@ -5714,6 +5819,9 @@ report reason */ /* No comment provided by engineer. */ "You accepted connection" = "Hai accettato la connessione"; +/* snd group event chat item */ +"you accepted this member" = "hai accettato questo membro"; + /* No comment provided by engineer. */ "You allow" = "Lo consenti"; @@ -5825,6 +5933,9 @@ report reason */ /* alert message */ "You can view invitation link again in connection details." = "Puoi vedere di nuovo il link di invito nei dettagli di connessione."; +/* alert message */ +"You can view your reports in Chat with admins." = "Puoi vedere le tue segnalazioni nella chat con gli amministratori."; + /* No comment provided by engineer. */ "You can't send messages!" = "Non puoi inviare messaggi!"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 8e4b071f88..481c21eb8b 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -2628,7 +2628,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "メンバーを除名する"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "メンバーを除名しますか?"; /* No comment provided by engineer. */ diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index f0ca51fffb..5caea12ee2 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -4169,7 +4169,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Lid verwijderen"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Lid verwijderen?"; /* No comment provided by engineer. */ diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index e3582c7647..e3e860e329 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -3746,7 +3746,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Usuń członka"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Usunąć członka?"; /* No comment provided by engineer. */ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index c14c7a7e9f..759a4c79f4 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -164,7 +164,7 @@ "%d file(s) were not downloaded." = "%d файлов не было загружено."; /* time interval */ -"%d hours" = "%d ч."; +"%d hours" = "%d час."; /* alert title */ "%d messages not forwarded" = "%d сообщений не переслано"; @@ -345,6 +345,12 @@ accept incoming call via notification swipe action */ "Accept" = "Принять"; +/* alert action */ +"Accept as member" = "Принять в группу"; + +/* alert action */ +"Accept as observer" = "Принять как читателя"; + /* No comment provided by engineer. */ "Accept conditions" = "Принять условия"; @@ -358,6 +364,12 @@ swipe action */ swipe action */ "Accept incognito" = "Принять инкогнито"; +/* alert title */ +"Accept member" = "Принять члена"; + +/* rcv group event chat item */ +"accepted %@" = "принят %@"; + /* call status */ "accepted call" = "принятый звонок"; @@ -367,6 +379,9 @@ swipe action */ /* chat list item title */ "accepted invitation" = "принятое приглашение"; +/* rcv group event chat item */ +"accepted you" = "Вы приняты"; + /* No comment provided by engineer. */ "Acknowledged" = "Подтверждено"; @@ -463,6 +478,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "шифрование согласовывается…"; +/* member criteria value */ +"all" = "все"; + /* No comment provided by engineer. */ "All" = "Все"; @@ -484,6 +502,9 @@ swipe action */ /* No comment provided by engineer. */ "All group members will remain connected." = "Все члены группы останутся соединены."; +/* feature role */ +"all members" = "все члены"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Все сообщения и файлы отправляются с **end-to-end шифрованием**, с постквантовой безопасностью в прямых разговорах."; @@ -502,6 +523,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Все сообщения о нарушениях будут заархивированы для вас."; +/* No comment provided by engineer. */ +"All servers" = "Все серверы"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Все контакты, которые соединились через этот адрес, сохранятся."; @@ -899,6 +923,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Не удаётся отправить сообщение члену группы"; +/* No comment provided by engineer. */ +"can't send messages" = "нельзя отправлять"; + /* alert action alert button */ "Cancel" = "Отменить"; @@ -1036,9 +1063,18 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Разговор будет удален для Вас - это действие нельзя отменить!"; +/* chat toolbar */ +"Chat with admins" = "Чат с админами"; + +/* No comment provided by engineer. */ +"Chat with member" = "Чат с членом группы"; + /* No comment provided by engineer. */ "Chats" = "Чаты"; +/* No comment provided by engineer. */ +"Chats with members" = "Чаты с членами группы"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Проверять сообщения каждые 20 минут."; @@ -1327,9 +1363,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "Существующий контакт"; +/* No comment provided by engineer. */ +"contact deleted" = "контакт удален"; + /* No comment provided by engineer. */ "Contact deleted!" = "Контакт удален!"; +/* No comment provided by engineer. */ +"contact disabled" = "контакт выключен"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "у контакта есть e2e шифрование"; @@ -1348,6 +1390,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "Имена контактов"; +/* No comment provided by engineer. */ +"contact not ready" = "контакт не готов"; + /* No comment provided by engineer. */ "Contact preferences" = "Предпочтения контакта"; @@ -1596,6 +1641,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat profile?" = "Удалить профиль?"; +/* alert title */ +"Delete chat with member?" = "Удалить чат с членом группы?"; + /* No comment provided by engineer. */ "Delete chat?" = "Удалить разговор?"; @@ -1782,6 +1830,9 @@ swipe action */ /* No comment provided by engineer. */ "Direct messages between members are prohibited in this chat." = "Личные сообщения запрещены в этой группе."; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited." = "Прямые сообщения между членами запрещены."; + /* No comment provided by engineer. */ "Disable (keep overrides)" = "Выключить (кроме исключений)"; @@ -1836,6 +1887,9 @@ swipe action */ /* No comment provided by engineer. */ "Do it later" = "Отложить"; +/* No comment provided by engineer. */ +"Do not send history to new members." = "Не отправлять историю новым членам."; + /* No comment provided by engineer. */ "Do NOT send messages directly, even if your or destination server does not support private routing." = "Не отправлять сообщения напрямую, даже если сервер получателя не поддерживает конфиденциальную доставку."; @@ -1933,6 +1987,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Включить доступ к камере"; +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных."; + /* No comment provided by engineer. */ "Enable for all" = "Включить для всех"; @@ -2101,6 +2158,12 @@ chat item action */ /* No comment provided by engineer. */ "Error accepting contact request" = "Ошибка при принятии запроса на соединение"; +/* alert title */ +"Error accepting member" = "Ошибка вступления члена группы"; + +/* No comment provided by engineer. */ +"Error adding member(s)" = "Ошибка при добавлении членов группы"; + /* alert title */ "Error adding server" = "Ошибка добавления сервера"; @@ -2137,6 +2200,9 @@ chat item action */ /* alert title */ "Error creating list" = "Ошибка создания списка"; +/* No comment provided by engineer. */ +"Error creating member contact" = "Ошибка при создании контакта"; + /* No comment provided by engineer. */ "Error creating message" = "Ошибка создания сообщения"; @@ -2152,6 +2218,9 @@ chat item action */ /* No comment provided by engineer. */ "Error deleting chat database" = "Ошибка при удалении данных чата"; +/* alert title */ +"Error deleting chat with member" = "Ошибка при удалении чата с членом группы"; + /* No comment provided by engineer. */ "Error deleting chat!" = "Ошибка при удалении чата!"; @@ -2215,6 +2284,9 @@ chat item action */ /* alert title */ "Error registering for notifications" = "Ошибка регистрации для уведомлений"; +/* alert title */ +"Error removing member" = "Ошибка при удалении члена группы"; + /* alert title */ "Error reordering lists" = "Ошибка сортировки списков"; @@ -2251,6 +2323,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending email" = "Ошибка отправки email"; +/* No comment provided by engineer. */ +"Error sending member contact invitation" = "Ошибка при отправке приглашения члену"; + /* No comment provided by engineer. */ "Error sending message" = "Ошибка при отправке сообщения"; @@ -2451,6 +2526,9 @@ snd error text */ /* No comment provided by engineer. */ "Fix not supported by contact" = "Починка не поддерживается контактом"; +/* No comment provided by engineer. */ +"Fix not supported by group member" = "Починка не поддерживается членом группы."; + /* No comment provided by engineer. */ "For all moderators" = "Для всех модераторов"; @@ -2529,6 +2607,9 @@ snd error text */ /* No comment provided by engineer. */ "Full name (optional)" = "Полное имя (не обязательно)"; +/* No comment provided by engineer. */ +"Fully decentralized – visible only to members." = "Группа полностью децентрализована – она видна только членам."; + /* No comment provided by engineer. */ "Fully re-implemented - work in background!" = "Полностью обновлены - работают в фоне!"; @@ -2577,6 +2658,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Приглашение в группу больше не действительно, оно было удалено отправителем."; +/* No comment provided by engineer. */ +"group is deleted" = "группа удалена"; + /* No comment provided by engineer. */ "Group link" = "Ссылка группы"; @@ -2595,12 +2679,18 @@ snd error text */ /* No comment provided by engineer. */ "Group profile" = "Профиль группы"; +/* No comment provided by engineer. */ +"Group profile is stored on members' devices, not on the servers." = "Профиль группы хранится на устройствах членов, а не на серверах."; + /* snd group event chat item */ "group profile updated" = "профиль группы обновлен"; /* No comment provided by engineer. */ "Group welcome message" = "Приветственное сообщение группы"; +/* No comment provided by engineer. */ +"Group will be deleted for all members - this cannot be undone!" = "Группа будет удалена для всех членов - это действие нельзя отменить!"; + /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Группа будет удалена для Вас - это действие нельзя отменить!"; @@ -2637,6 +2727,9 @@ snd error text */ /* No comment provided by engineer. */ "History" = "История"; +/* No comment provided by engineer. */ +"History is not sent to new members." = "История не отправляется новым членам."; + /* time unit */ "hours" = "часов"; @@ -2871,6 +2964,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Пригласить друзей"; +/* No comment provided by engineer. */ +"Invite members" = "Пригласить членов группы"; + /* No comment provided by engineer. */ "Invite to chat" = "Пригласить в разговор"; @@ -3084,15 +3180,75 @@ snd error text */ /* blur media */ "Medium" = "Среднее"; +/* member role */ +"member" = "член группы"; + +/* No comment provided by engineer. */ +"Member" = "Член группы"; + +/* profile update event chat item */ +"member %@ changed to %@" = "член %1$@ изменился на %2$@"; + +/* No comment provided by engineer. */ +"Member admission" = "Приём членов в группу"; + /* rcv group event chat item */ "member connected" = "соединен(а)"; +/* No comment provided by engineer. */ +"member has old version" = "член имеет старую версию"; + +/* item status text */ +"Member inactive" = "Член неактивен"; + /* chat feature */ "Member reports" = "Сообщения о нарушениях"; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All chat members will be notified." = "Роль участника будет изменена на \"%@\". Все участники разговора получат уведомление."; +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All group members will be notified." = "Роль члена будет изменена на \"%@\". Все члены группы получат уведомление."; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль члена будет изменена на \"%@\". Будет отправлено новое приглашение."; + +/* No comment provided by engineer. */ +"Member will be removed from chat - this cannot be undone!" = "Член будет удален из разговора - это действие нельзя отменить!"; + +/* No comment provided by engineer. */ +"Member will be removed from group - this cannot be undone!" = "Член группы будет удален - это действие нельзя отменить!"; + +/* alert message */ +"Member will join the group, accept member?" = "Участник хочет присоединиться к группе. Принять?"; + +/* No comment provided by engineer. */ +"Members can add message reactions." = "Члены могут добавлять реакции на сообщения."; + +/* No comment provided by engineer. */ +"Members can irreversibly delete sent messages. (24 hours)" = "Члены могут необратимо удалять отправленные сообщения. (24 часа)"; + +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Члены группы могут пожаловаться модераторам."; + +/* No comment provided by engineer. */ +"Members can send direct messages." = "Члены могут посылать прямые сообщения."; + +/* No comment provided by engineer. */ +"Members can send disappearing messages." = "Члены могут посылать исчезающие сообщения."; + +/* No comment provided by engineer. */ +"Members can send files and media." = "Члены могут слать файлы и медиа."; + +/* No comment provided by engineer. */ +"Members can send SimpleX links." = "Члены могут отправлять ссылки SimpleX."; + +/* No comment provided by engineer. */ +"Members can send voice messages." = "Члены могут отправлять голосовые сообщения."; + +/* No comment provided by engineer. */ +"Mention members 👋" = "Упоминайте участников 👋"; + /* No comment provided by engineer. */ "Menus" = "Меню"; @@ -3114,6 +3270,9 @@ snd error text */ /* item status text */ "Message forwarded" = "Сообщение переслано"; +/* item status description */ +"Message may be delivered later if member becomes active." = "Сообщение может быть доставлено позже, если член группы станет активным."; + /* No comment provided by engineer. */ "Message queue info" = "Информация об очереди сообщений"; @@ -3330,6 +3489,12 @@ snd error text */ /* No comment provided by engineer. */ "New media options" = "Новые медиа-опции"; +/* No comment provided by engineer. */ +"New member role" = "Роль члена группы"; + +/* rcv group event chat item */ +"New member wants to join the group." = "Новый участник хочет присоединиться к группе."; + /* notification */ "new message" = "новое сообщение"; @@ -3369,6 +3534,9 @@ snd error text */ /* No comment provided by engineer. */ "No chats in list %@" = "Нет чатов в списке %@"; +/* No comment provided by engineer. */ +"No chats with members" = "Нет чатов с членами группы"; + /* No comment provided by engineer. */ "No contacts selected" = "Контакты не выбраны"; @@ -3453,6 +3621,9 @@ snd error text */ /* No comment provided by engineer. */ "Not compatible!" = "Несовместимая версия!"; +/* No comment provided by engineer. */ +"not synchronized" = "не синхронизирован"; + /* No comment provided by engineer. */ "Notes" = "Заметки"; @@ -3477,6 +3648,9 @@ snd error text */ /* alert title */ "Notifications status" = "Статус уведомлений"; +/* No comment provided by engineer. */ +"Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Теперь админы могут:\n- удалять сообщения членов.\n- приостанавливать членов (роль наблюдатель)"; + /* member role */ "observer" = "читатель"; @@ -3592,6 +3766,9 @@ alert button */ /* No comment provided by engineer. */ "Open group" = "Открыть группу"; +/* alert title */ +"Open link?" = "Открыть ссылку?"; + /* authentication reason */ "Open migration to another device" = "Открытие миграции на другое устройство"; @@ -3667,6 +3844,9 @@ alert button */ /* No comment provided by engineer. */ "Password to show" = "Пароль чтобы раскрыть"; +/* past/unknown group member */ +"Past member %@" = "Бывший член %@"; + /* No comment provided by engineer. */ "Paste desktop address" = "Вставить адрес компьютера"; @@ -3691,6 +3871,9 @@ alert button */ /* No comment provided by engineer. */ "pending approval" = "ожидает утверждения"; +/* No comment provided by engineer. */ +"pending review" = "ожидает одобрения"; + /* No comment provided by engineer. */ "Periodic" = "Периодически"; @@ -3760,6 +3943,9 @@ alert button */ /* token info */ "Please try to disable and re-enable notfications." = "Попробуйте выключить и снова включить уведомления."; +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Пожалуйста, подождите, пока модераторы группы рассмотрят ваш запрос на вступление."; + /* token info */ "Please wait for token activation to complete." = "Пожалуйста, дождитесь завершения активации токена."; @@ -3859,6 +4045,9 @@ alert button */ /* No comment provided by engineer. */ "Prohibit reporting messages to moderators." = "Запретить жаловаться модераторам группы."; +/* No comment provided by engineer. */ +"Prohibit sending direct messages to members." = "Запретить посылать прямые сообщения членам группы."; + /* No comment provided by engineer. */ "Prohibit sending disappearing messages." = "Запретить посылать исчезающие сообщения."; @@ -4037,6 +4226,9 @@ swipe action */ /* No comment provided by engineer. */ "Reject contact request" = "Отклонить запрос"; +/* alert title */ +"Reject member?" = "Отклонить участника?"; + /* No comment provided by engineer. */ "rejected" = "отклонён"; @@ -4058,6 +4250,12 @@ swipe action */ /* No comment provided by engineer. */ "Remove image" = "Удалить изображение"; +/* No comment provided by engineer. */ +"Remove member" = "Удалить члена группы"; + +/* No comment provided by engineer. */ +"Remove member?" = "Удалить члена группы?"; + /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Удалить пароль из Keychain?"; @@ -4070,6 +4268,9 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "удалён адрес контакта"; +/* No comment provided by engineer. */ +"removed from group" = "удален из группы"; + /* profile update event chat item */ "removed profile picture" = "удалена картинка профиля"; @@ -4118,6 +4319,9 @@ swipe action */ /* No comment provided by engineer. */ "Report reason?" = "Причина сообщения?"; +/* alert title */ +"Report sent to moderators" = "Жалоба отправлена модераторам"; + /* report reason */ "Report spam: only group moderators will see it." = "Пожаловаться на спам: увидят только модераторы группы."; @@ -4133,6 +4337,9 @@ swipe action */ /* No comment provided by engineer. */ "Reports" = "Сообщения о нарушениях"; +/* No comment provided by engineer. */ +"request to join rejected" = "запрос на вступление отклонён"; + /* chat list item title */ "requested to connect" = "запрошено соединение"; @@ -4187,9 +4394,21 @@ swipe action */ /* chat item action */ "Reveal" = "Показать"; +/* No comment provided by engineer. */ +"review" = "рассмотрение"; + /* No comment provided by engineer. */ "Review conditions" = "Посмотреть условия"; +/* admission stage */ +"Review members" = "Одобрять членов"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Одобрять членов для вступления в группу."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "одобрен админами"; + /* No comment provided by engineer. */ "Revoke" = "Отозвать"; @@ -4218,9 +4437,15 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "Сохранить (и уведомить контакты)"; +/* alert title */ +"Save admission settings?" = "Сохранить настройки вступления?"; + /* alert button */ "Save and notify contact" = "Сохранить и уведомить контакт"; +/* No comment provided by engineer. */ +"Save and notify group members" = "Сохранить и уведомить членов группы"; + /* No comment provided by engineer. */ "Save and reconnect" = "Сохранить и переподключиться"; @@ -4413,6 +4638,9 @@ chat item action */ /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Отправьте из галереи или из дополнительных клавиатур."; +/* No comment provided by engineer. */ +"Send up to 100 last messages to new members." = "Отправить до 100 последних сообщений новым членам."; + /* alert message */ "Sender cancelled file transfer." = "Отправитель отменил передачу файла."; @@ -4548,6 +4776,9 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Установите код вместо системной аутентификации."; +/* No comment provided by engineer. */ +"Set member admission" = "Приём членов в группу"; + /* No comment provided by engineer. */ "Set message expiration in chats." = "Установите срок хранения сообщений в чатах."; @@ -4566,6 +4797,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "Установите пароль"; +/* No comment provided by engineer. */ +"Set the message shown to new members!" = "Установить сообщение для новых членов группы!"; + /* No comment provided by engineer. */ "Set timeouts for proxy/VPN" = "Установить таймауты для прокси/VPN"; @@ -4618,6 +4852,9 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Поделиться с контактами"; +/* No comment provided by engineer. */ +"Short link" = "Короткая ссылка"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Показать → на сообщениях доставленных конфиденциально."; @@ -4660,6 +4897,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX address or 1-time link?" = "Адрес SimpleX или одноразовая ссылка?"; +/* simplex link type */ +"SimpleX channel link" = "SimpleX ссылка канала"; + /* No comment provided by engineer. */ "SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat и Flux заключили соглашение добавить серверы под управлением Flux в приложение."; @@ -4955,6 +5195,18 @@ report reason */ /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Неправильный ID предыдущего сообщения (меньше или равен предыдущему).\nЭто может произойти из-за ошибки программы, или когда соединение компроментировано."; +/* No comment provided by engineer. */ +"The message will be deleted for all members." = "Сообщение будет удалено для всех членов группы."; + +/* No comment provided by engineer. */ +"The message will be marked as moderated for all members." = "Сообщение будет помечено как удаленное для всех членов группы."; + +/* No comment provided by engineer. */ +"The messages will be deleted for all members." = "Сообщения будут удалены для всех членов группы."; + +/* No comment provided by engineer. */ +"The messages will be marked as moderated for all members." = "Сообщения будут помечены как удаленные для всех членов группы."; + /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Предыдущая версия данных чата не удалена при перемещении, её можно удалить."; @@ -5021,6 +5273,9 @@ report reason */ /* No comment provided by engineer. */ "This display name is invalid. Please choose another name." = "Ошибка имени профиля. Пожалуйста, выберите другое имя."; +/* No comment provided by engineer. */ +"This group has over %lld members, delivery receipts are not sent." = "В этой группе более %lld членов, отчёты о доставке не отправляются."; + /* No comment provided by engineer. */ "This group no longer exists." = "Эта группа больше не существует."; @@ -5030,6 +5285,9 @@ report reason */ /* No comment provided by engineer. */ "This is your own SimpleX address!" = "Это ваш собственный адрес SimpleX!"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Эта ссылка была использована на другом мобильном, пожалуйста, создайте новую ссылку на компьютере."; @@ -5141,6 +5399,15 @@ report reason */ /* No comment provided by engineer. */ "Unblock for all" = "Разблокировать для всех"; +/* No comment provided by engineer. */ +"Unblock member" = "Разблокировать члена группы"; + +/* No comment provided by engineer. */ +"Unblock member for all?" = "Разблокировать члена для всех?"; + +/* No comment provided by engineer. */ +"Unblock member?" = "Разблокировать члена группы?"; + /* rcv group event chat item */ "unblocked %@" = "%@ разблокирован"; @@ -5213,6 +5480,12 @@ report reason */ /* swipe action */ "Unread" = "Не прочитано"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Ссылка не поддерживается"; + +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new members." = "До 100 последних сообщений отправляются новым членам."; + /* No comment provided by engineer. */ "Update" = "Обновить"; @@ -5303,6 +5576,9 @@ report reason */ /* No comment provided by engineer. */ "Use servers" = "Использовать серверы"; +/* No comment provided by engineer. */ +"Use short links (BETA)" = "Короткие ссылки (БЕТА)"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; @@ -5312,6 +5588,9 @@ report reason */ /* No comment provided by engineer. */ "Use TCP port %@ when no port is specified." = "Использовать TCP-порт %@, когда порт не указан."; +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Использовать TCP-порт 443 только для серверов по умолчанию."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Используйте приложение во время звонка."; @@ -5540,6 +5819,9 @@ report reason */ /* No comment provided by engineer. */ "You accepted connection" = "Вы приняли приглашение соединиться"; +/* snd group event chat item */ +"you accepted this member" = "Вы приняли этого члена"; + /* No comment provided by engineer. */ "You allow" = "Вы разрешаете"; @@ -5630,6 +5912,9 @@ report reason */ /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Вы можете установить просмотр уведомлений на экране блокировки в настройках."; +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Вы можете поделиться ссылкой или QR кодом - через них можно присоединиться к группе. Вы сможете удалить ссылку, сохранив членов группы, которые через нее соединились."; + /* No comment provided by engineer. */ "You can share this address with your contacts to let them connect with **%@**." = "Вы можете поделиться этим адресом с Вашими контактами, чтобы они могли соединиться с **%@**."; @@ -5648,6 +5933,9 @@ report reason */ /* alert message */ "You can view invitation link again in connection details." = "Вы можете увидеть ссылку-приглашение снова открыв соединение."; +/* alert message */ +"You can view your reports in Chat with admins." = "Вы можете найти Ваши жалобы в Чате с админами."; + /* No comment provided by engineer. */ "You can't send messages!" = "Вы не можете отправлять сообщения!"; @@ -5684,6 +5972,9 @@ report reason */ /* No comment provided by engineer. */ "You joined this group" = "Вы вступили в эту группу"; +/* No comment provided by engineer. */ +"You joined this group. Connecting to inviting group member." = "Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы."; + /* snd group event chat item */ "you left" = "Вы покинули группу"; @@ -5738,6 +6029,9 @@ report reason */ /* No comment provided by engineer. */ "You will be required to authenticate when you start or resume the app after 30 seconds in background." = "Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме."; +/* No comment provided by engineer. */ +"You will connect to all group members." = "Вы соединитесь со всеми членами группы."; + /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Вы все равно получите звонки и уведомления в профилях без звука, когда они активные."; @@ -5833,4 +6127,3 @@ report reason */ /* No comment provided by engineer. */ "Your SimpleX address" = "Ваш адрес SimpleX"; - diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index d6e48caf86..a6ef88d0d4 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -2340,7 +2340,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "ลบสมาชิกออก"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "ลบสมาชิกออก?"; /* No comment provided by engineer. */ diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index 2433c9ae58..3d44c895ec 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -3782,7 +3782,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Kişiyi sil"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Kişi silinsin mi?"; /* No comment provided by engineer. */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 6e6197cdb7..932c29d368 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -3854,7 +3854,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Видалити учасника"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "Видалити учасника?"; /* No comment provided by engineer. */ diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 9df0e04717..19d7c268d4 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -4109,7 +4109,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "删除成员"; -/* alert title */ +/* No comment provided by engineer. */ "Remove member?" = "删除成员吗?"; /* No comment provided by engineer. */ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index fbb9f94a64..53f31ecba9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1014,7 +1014,7 @@ Moderieren Diese Nachricht wird für alle Mitglieder als moderiert gekennzeichnet. Sie sind Beobachter - Sie können keine Nachrichten versenden! + Sie sind Beobachter Beobachter Anfängliche Rolle Nachricht des Mitglieds löschen\? @@ -2466,7 +2466,7 @@ %d Chat(s) Meldung wurde an die Moderatoren gesendet Sie haben dieses Mitglied übernommen - Überprüfung der Mitglieder vor der Aufnahme (Anklopfen). + Überprüfung der Mitglieder vor der Aufnahme (\"Anklopfen\"). Überprüfung der Mitglieder alle Aus @@ -2489,10 +2489,25 @@ Chat mit Administratoren %1$s übernommen Als Mitglied übernehmen - Fehler beim Übernehmen eines Mitglieds + Fehler beim Übernehmen des Mitglieds Aufnahme von Mitgliedern Ausstehende Überprüfung Chat mit einem Mitglied Übernehmen Bitte warten Sie auf die Überprüfung Ihrer Anfrage durch die Gruppen-Moderatoren, um der Gruppe beitreten zu können. + Gruppe wird gelöscht + Beitrittsanfrage abgelehnt + Von der Gruppe entfernt + Sie haben die Gruppe verlassen + Kontakt deaktiviert + Nicht synchronisiert + Fehler beim Löschen des Chats mit dem Mitglied + Kontakt nicht bereit + Sie können keine Nachrichten senden! + Chat löschen + Chat mit dem Mitglied löschen? + Mitglied ablehnen? + Es können keine Nachrichten gesendet werden + Kontakt gelöscht + Das Mitglied hat eine alte App-Version diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index bf920e2fca..d15729be18 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -940,7 +940,7 @@ Errore nell\'aggiornamento del link del gruppo osservatore Contatta l\'amministratore del gruppo. - Non puoi inviare messaggi! + sei un osservatore Sistema Aggiungi messaggio di benvenuto Messaggio di benvenuto @@ -2414,14 +2414,29 @@ Accetta membro Chatta con gli amministratori Accetta come osservatore - Il nuovo membro vuole entrare nel gruppo. + Un nuovo membro vuole entrare nel gruppo. tutti Chatta con il membro Chat con membri Errore di accettazione del membro Accetta come membro - Imposta l\'ammissione del membro + Imposta l\'ammissione dei membri Segnalazione inviata ai moderatori in attesa di revisione - Puoi vedere i tuoi resoconti nella chat con gli amministratori. + Puoi vedere le tue segnalazioni nella chat con gli amministratori. + Non puoi inviare messaggi! + contatto non pronto + contatto eliminato + contatto disattivato + non sincronizzato + richiesta di entrare rifiutata + impossibile inviare messaggi + il gruppo è eliminato + il membro ha una versione vecchia + rimosso dal gruppo + sei uscito/a + Eliminare la chat con il membro? + Rifiutare il membro? + Elimina chat + Errore di eliminazione della chat con il membro diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 97742f82a8..dae44e06af 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1019,7 +1019,7 @@ Сообщение будет удалено для всех членов группы. Сообщение будет помечено как удаленное для всех членов группы. Пожалуйста, свяжитесь с админом группы. - Вы не можете отправлять сообщения! + Вы \"читатель\" только чтение сообщений читатель Роль при вступлении @@ -1132,7 +1132,7 @@ Ошибка расшифровки Блокировка SimpleX не включена! Ошибка хэш сообщения - Хэш предыдущего сообщения отличается\" + Хэш предыдущего сообщения отличается. Подтвердить код Неправильный код Заблокировать через @@ -2467,4 +2467,58 @@ Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. Настроить операторов серверов Политика конфиденциальности и условия использования. + все + Принять + Участник хочет присоединиться к группе. Принять? + группа удалена + удален из группы + %d чата(ов) + контакт не готов + контакт удален + не синхронизирован + запрос на вступление отклонён + Новый участник хочет присоединиться к группе. + Пожалуйста, подождите, пока модераторы группы рассмотрят ваш запрос на вступление. + ожидает одобрения + Отклонить + Отклонить участника? + Ошибка при удалении чата с членом группы + Полная ссылка + Ошибка вступления члена группы + Ссылка не поддерживается + Эта ссылка требует новую версию. Обновите приложение или попросите Ваш контакт прислать совместимую ссылку. + %d сообщений + Вы можете найти Ваши жалобы в Чате с админами. + Чат с админами + Чат с членом группы + выключено + Одобрять членов + Чаты с членами группы + Приём членов в группу + Одобрять членов для вступления в группу. + Нет чатов с членами группы + Принять как читателя + Принять в группу + Принять члена + одобрен админами + Жалоба отправлена модераторам + Вы вышли + нельзя отправлять + %d чатов с членами группы + контакт выключен + член имеет старую версию + Вы не можете отправлять сообщения! + Короткая ссылка + Сохранить настройки вступления? + Вы приняли этого члена + рассмотрение + Установить вступление в группу + Удалить чат с членом группы? + Удалить разговор + принят %1$s + Чат с админами + Вы приняты + 1 чат с членом группы + SimpleX ссылка канала + Короткие ссылки (БЕТА) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index a72a4938c2..e78878000c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -929,7 +929,7 @@ 观察员 你是观察者 更新群链接错误 - 你无法发送消息! + 你是观察员 初始角色 请联系群管理员。 系统 @@ -2408,4 +2408,19 @@ 你可以在和管理员和聊天中查看你的举报。 接受为观察员 和一名成员的一个聊天 + 无法发送消息 + 你离开了 + 删除和成员的聊天出错 + 你无法发送消息! + 禁用了联系人 + 群被删除了 + 从群被删除了 + 加入请求被拒绝 + 删除聊天 + 删除和成员的聊天吗? + 未同步 + 成员有旧版本 + 删除了联系人 + 联系人未就绪 + 拒绝成员? From dd2dc16fbf9fa95a9f8cdd8e6831a0d8abcd7f53 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 21 May 2025 11:49:58 +0100 Subject: [PATCH 61/72] website: translations (#5937) * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Russian) Currently translated at 100.0% (257 of 257 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ --------- Co-authored-by: summoner001 Co-authored-by: Ghost of Sparta --- website/langs/hu.json | 10 +++++----- website/langs/ru.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/langs/hu.json b/website/langs/hu.json index a55136d26a..f512cfa7cf 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -16,7 +16,7 @@ "simplex-explained-tab-2-p-1": "Minden kapcsolathoz két különböző üzenetküldési sorbaállítást használ a különböző kiszolgálókon keresztül történő üzenetküldéshez és -fogadáshoz.", "simplex-explained-tab-2-p-2": "A kiszolgálók csak egyetlen irányba továbbítják az üzeneteket, anélkül, hogy teljes képet kapnának a felhasználók beszélgetéseiről vagy kapcsolatairól.", "simplex-explained-tab-3-p-1": "A kiszolgálók minden egyes üzenetsorbaállításhoz külön névtelen hitelesítő-adatokkal rendelkeznek, és nem tudják, hogy melyik felhasználóhoz tartoznak.", - "simplex-explained-tab-3-p-2": "A felhasználók tovább fokozhatják a metaadatok adatvédelmét, ha a Tor segítségével férnek hozzá a kiszolgálókhoz, így megakadályozva az IP-cím szerinti korrelációt.", + "simplex-explained-tab-3-p-2": "A felhasználók tovább fokozhatják a metaadatok adatvédelmét, ha a Tor-hálózat használatával férnek hozzá a kiszolgálókhoz, így megakadályozva az IP-cím szerinti korrelációt.", "smp-protocol": "SMP-protokoll", "chat-protocol": "Csevegési protokoll", "donate": "Adományozás", @@ -59,7 +59,7 @@ "simplex-private-card-3-point-1": "A kliens és a kiszolgálók közötti kapcsolatokhoz csak az erős algoritmusokkal rendelkező TLS 1.2/1.3 protokollt használja.", "simplex-private-card-3-point-2": "A kiszolgáló ujjlenyomata és a csatornakötés megakadályozza a MITM- és a visszajátszási támadásokat.", "simplex-private-card-3-point-3": "Az újrakapcsolódás le van tiltva a munkamenet elleni támadások megelőzése érdekében.", - "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a Tor hálózaton vagy más átvitelátfedő hálózaton keresztül is elérheti.", + "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a Tor-hálózaton vagy más átvitelátfedő hálózaton keresztül is elérheti.", "simplex-private-card-6-point-1": "Számos kommunikációs hálózat sebezhető a kiszolgálók vagy a hálózat-szolgáltatók MITM-támadásaival szemben.", "simplex-private-card-6-point-2": "Ennek megakadályozása érdekében a SimpleX-alkalmazások egyszeri kulcsokat adnak át sávon kívül, amikor egy címet hivatkozásként vagy QR-kódként oszt meg.", "simplex-private-card-7-point-1": "Az integritás garantálása érdekében az üzenetek sorszámozással vannak ellátva, és tartalmazzák az előző üzenet hasítóértékét.", @@ -87,7 +87,7 @@ "simplex-unique-4-overlay-1-title": "Teljesen decentralizált — a SimpleX-hálózat a felhasználóké", "hero-overlay-card-1-p-1": "Sok felhasználó kérdezte: ha a SimpleXnek nincsenek felhasználói azonosítói, honnan tudja, hogy hová kell eljuttatni az üzeneteket?", "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi hálózat által használt felhasználói azonosítók helyett a SimpleX az üzenetek sorbaállításához ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", - "hero-overlay-card-1-p-4": "Ez a kialakítás megakadályozza a felhasználók metaadatainak kiszivárgását az alkalmazás szintjén. Az adatvédelem további javítása és az IP-cím védelme érdekében az üzenetküldő kiszolgálókhoz Tor hálózaton keresztül is kapcsolódhat.", + "hero-overlay-card-1-p-4": "Ez a kialakítás megakadályozza a felhasználók metaadatainak kiszivárgását az alkalmazás szintjén. Az adatvédelem további javítása és az IP-cím védelme érdekében az üzenetküldő kiszolgálókhoz Tor-hálózaton keresztül is kapcsolódhat.", "hero-overlay-card-1-p-5": "Csak a kliensek tárolják a felhasználói profilokat, kapcsolatokat és csoportokat; az üzenetek küldése 2 rétegű végpontok közötti titkosítással történik.", "hero-overlay-card-1-p-6": "További leírást a SimpleX ismertetőben olvashat.", "hero-overlay-card-2-p-1": "Ha a felhasználók állandó azonosítóval rendelkeznek, még akkor is, ha ez csak egy véletlenszerű szám, például egy munkamenet-azonosító, fennáll annak a veszélye, hogy a szolgáltató vagy egy támadó megfigyelheti, azt hogy hogyan kapcsolódnak a felhasználók egymáshoz, és hány üzenetet küldenek egymásnak.", @@ -114,7 +114,7 @@ "privacy-matters-overlay-card-3-p-4": "Nem elég csak egy végpontok között titkosított üzenetváltó-alkalmazást használnunk, mindannyiunknak olyan üzenetváltó-alkalmazásokat kell használnunk, amelyek védik a személyes partnereink magánéletét — akikkel kapcsolatban állunk.", "simplex-unique-overlay-card-1-p-1": "Más üzenetküldő hálózatoktól eltérően a SimpleX nem rendel azonosítókat a felhasználókhoz. Nem támaszkodik telefonszámokra, tartomány-alapú címekre (mint az e-mail, XMPP vagy a Matrix), felhasználónevekre, nyilvános kulcsokra vagy akár véletlenszerű számokra a felhasználók azonosításához — a SimpleX-kiszolgálók üzemeltetői nem tudják, hogy hányan használják a kiszolgálóikat.", "simplex-unique-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez a SimpleX az egyirányú üzenet várakoztatást használ páronkénti névtelen címekkel, külön a fogadott és külön az elküldött üzenetek számára, általában különböző kiszolgálókon keresztül.", - "simplex-unique-overlay-card-1-p-3": "Ez a kialakítás megvédi annak titkosságát, hogy kivel kommunikál, elrejtve azt a SimpleX-hálózat kiszolgálói és a megfigyelők elől. IP-címének a kiszolgálók elől való elrejtéséhez azt teheti meg, hogy Toron keresztül kapcsolódik a SimpleX-kiszolgálókhoz.", + "simplex-unique-overlay-card-1-p-3": "Ez a kialakítás védi partnerének adatait, elrejtve azt a SimpleX-hálózat kiszolgálói és a külső megfigyelők elől. Az IP-címe elrejtésének érdekében aTor-hálózaton keresztül is kapcsolódhat a SimpleX-kiszolgálókhoz.", "simplex-unique-overlay-card-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX-hálózaton, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", "simplex-unique-overlay-card-2-p-2": "Még a felhasználói cím használata esetén is, aminek használata nem kötelező – ugyanakkor ez a kéretlen kapcsolatkérelmek küldésére is használható – módosíthatja vagy teljesen törölheti anélkül, hogy elveszítené a meglévő kapcsolatait.", "simplex-unique-overlay-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban, amely exportálható és átvihető bármely más támogatott eszközre.", @@ -256,4 +256,4 @@ "stable-and-beta-versions-built-by-developers": "A fejlesztők által készített stabil és béta verziók", "hero-overlay-card-3-p-3": "A Trail of Bits 2024 júliusában ismét auditálta a SimpleX-protokollok kriptográfiai és hálózati komponenseit. További információk.", "docs-dropdown-14": "SimpleX üzleti célra" -} \ No newline at end of file +} diff --git a/website/langs/ru.json b/website/langs/ru.json index 7f8e191ccd..ea7766db1b 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -218,7 +218,7 @@ "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и увеличивать трафик, что приводит к отказу всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для создания трафика во всей сети.", "if-you-already-installed-simplex-chat-for-the-terminal": "Если Вы уже установили SimpleX Chat для терминала", "docs-dropdown-8": "Служба Каталогов SimpleX", - "simplex-private-card-1-point-1": "Протокол двойного обновления ключей —
\"отрицаемые\" сообщения с идеальной прямой секретностью и восстановлением после взлома", + "simplex-private-card-1-point-1": "Протокол двойного обновления ключей —
\"отрицаемые\" сообщения с идеальной прямой секретностью и восстановлением после взлома.", "simplex-private-card-8-point-1": "Серверы SimpleX действуют как узлы-миксеры с низкой задержкой — входящие и исходящие сообщения имеют разный порядок.", "simplex-unique-overlay-card-2-p-1": "Поскольку у Вас нет идентификатора в сети SimpleX, никто не сможет связаться с Вами, если Вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", "sign-up-to-receive-our-updates": "Подпишитесь на нашу рассылку новостей", From 1b75ca5258333b6d222cdbceaad17308c3c5cb42 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 21 May 2025 13:33:55 +0100 Subject: [PATCH 62/72] 6.4-beta.1: ios 279, android 291, desktop 103 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++--- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0a0d5b4458..42e36a78c9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -179,8 +179,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.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.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 */; }; @@ -543,8 +543,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.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.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 = ""; }; @@ -702,8 +702,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -788,8 +788,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.1-7Y3Lr8U6bNmEaeIx88dGf7.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.2-K4qWCwk6PxbL8qHn42QC4F.a */, ); path = Libraries; sourceTree = ""; @@ -1991,7 +1991,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2041,7 +2041,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2083,7 +2083,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2103,7 +2103,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2128,7 +2128,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2165,7 +2165,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2202,7 +2202,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2253,7 +2253,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2304,7 +2304,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2338,7 +2338,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 278; + CURRENT_PROJECT_VERSION = 279; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 4623752b32..db12d47dbd 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.4-beta.0 -android.version_code=290 +android.version_name=6.4-beta.1 +android.version_code=291 -desktop.version_name=6.4-beta.0 -desktop.version_code=102 +desktop.version_name=6.4-beta.1 +desktop.version_code=103 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From cf0639bf2851ac1aa266ef5908530a83a4505daf Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 5 Jun 2025 21:05:16 +0100 Subject: [PATCH 63/72] website: add Whonix to reviews (#5966) --- README.md | 20 ++++++++++---------- images/privacy-guides.jpg | Bin 11225 -> 11430 bytes images/whonix-logo.jpg | Bin 0 -> 8183 bytes website/src/_includes/hero.html | 8 ++++++-- website/src/img/whonix-dark.png | Bin 0 -> 36012 bytes website/src/img/whonix-light.png | Bin 0 -> 29922 bytes 6 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 images/whonix-logo.jpg create mode 100644 website/src/img/whonix-dark.png create mode 100644 website/src/img/whonix-light.png diff --git a/README.md b/README.md index 40d552b84d..554c6068d9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ # SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! -[](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)     [](https://www.privacyguides.org/en/real-time-communication/#simplex-chat)     [](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) +[](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)     [](https://www.privacyguides.org/en/real-time-communication/#simplex-chat)     [](https://www.whonix.org/wiki/Chat#Recommendation)     [](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) ## Welcome to SimpleX Chat! @@ -110,6 +110,15 @@ After you connect, you can [verify connection security code](./blog/20230103-sim Read about the app features and settings in the new [User guide](./docs/guide/README.md). +## Contribute + +We would love to have you join the development! You can help us with: + +- [share the color theme](./docs/THEMES.md) you use in Android app! +- writing a tutorial or recipes about hosting servers, chat bot automations, etc. +- contributing to SimpleX Chat knowledge-base. +- developing features - please connect to us via chat so we can help you get started. + ## Help translating SimpleX Chat Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages. @@ -141,15 +150,6 @@ Join our translators to help SimpleX grow! Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us! -## Contribute - -We would love to have you join the development! You can help us with: - -- [share the color theme](./docs/THEMES.md) you use in Android app! -- writing a tutorial or recipes about hosting servers, chat bot automations, etc. -- contributing to SimpleX Chat knowledge-base. -- developing features - please connect to us via chat so we can help you get started. - ## Please support us with your donations Huge thank you to everybody who donated to SimpleX Chat! diff --git a/images/privacy-guides.jpg b/images/privacy-guides.jpg index f15a8862f7aaf91449031a0c11e8b669bb11f11f..5876d10c02a5f1bf13a746fec056c7a94d957cef 100644 GIT binary patch literal 11430 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 + + Whonix + + Heise Online @@ -43,7 +47,7 @@ Opt Out Podcast - +
@@ -66,7 +70,7 @@ - + {#

PLAY

diff --git a/website/src/img/whonix-dark.png b/website/src/img/whonix-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..f361f4d1f6c1429ca368654146e59b8f5041b0e2 GIT binary patch literal 36012 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 Date: Fri, 6 Jun 2025 11:58:44 +0100 Subject: [PATCH 64/72] core: 6.3.4.2 (simplexmq 6.4.0.3) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- tests/ChatClient.hs | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index 3e6ccab8a5..44305353f2 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: deaec3cce286e959bd594b9620c307954b510a07 + tag: f44ea0a6d8eec8abf4af177ebeb91629f7d89165 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8d17a2ce99..0c29b47a36 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."deaec3cce286e959bd594b9620c307954b510a07" = "0b8m4czjiwsi9169plslyk2rjw0f370vv7ha6qm2hpx14bxzz7xm"; + "https://github.com/simplex-chat/simplexmq.git"."f44ea0a6d8eec8abf4af177ebeb91629f7d89165" = "1biq1kq33v7hnacbhllry9n5c6dmh9dyqnz8hc5abgsv1z38qb1a"; "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 a9dca273e3..f3691ab1a6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.3.4.1 +version: 6.3.4.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 2d0bde5058..e3bab5a0ec 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -17,6 +17,7 @@ import Control.Concurrent (forkIOWithUnmask, killThread, threadDelay) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, bracket_) +import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.Reader @@ -519,7 +520,7 @@ smpServerCfg = allowSMPProxy = True, serverClientConcurrency = 16, information = Nothing, - startOptions = StartOptions {maintenance = False, compactLog = False, skipWarnings = False, confirmMigrations = MCYesUp} + startOptions = StartOptions {maintenance = False, logLevel = LogError, compactLog = False, skipWarnings = False, confirmMigrations = MCYesUp} } persistentServerStoreCfg :: FilePath -> AServerStoreCfg From 50dfda6c0908a9c95391c85bcac70bfbc5237354 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 8 Jun 2025 18:27:42 +0100 Subject: [PATCH 65/72] core: fix deletion queries for PostgreSQL client (#5969) * core: fix deletion queries for PostgreSQL client * disable test in posrgres * plan --- src/Simplex/Chat/Store/Messages.hs | 10 ++++++++-- .../Chat/Store/SQLite/Migrations/chat_query_plans.txt | 2 +- tests/ChatTests/Groups.hs | 7 +++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index bdbb3fe67d..752a4a2c6d 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -171,7 +171,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM #if defined(dbPostgres) -import Database.PostgreSQL.Simple (FromRow, Only (..), Query, ToRow, (:.) (..)) +import Database.PostgreSQL.Simple (FromRow, In (..), Only (..), Query, ToRow, (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) #else import Database.SQLite.Simple (FromRow, Only (..), Query, ToRow, (:.) (..)) @@ -2370,8 +2370,14 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb updateMemberCIsModerated :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> UTCTime -> IO () updateMemberCIsModerated db User {userId} GroupInfo {groupId, membership} member byGroupMember md deletedTs = do itemIds <- updateCIs =<< getCurrentTime +#if defined(dbPostgres) + let inItemIds = Only $ In (map fromOnly itemIds) + DB.execute db "DELETE FROM messages WHERE message_id IN (SELECT message_id FROM chat_item_messages WHERE chat_item_id IN ?)" inItemIds + DB.execute db "DELETE FROM chat_item_versions WHERE chat_item_id IN ?" inItemIds +#else DB.executeMany db deleteChatItemMessagesQuery itemIds DB.executeMany db "DELETE FROM chat_item_versions WHERE chat_item_id = ?" itemIds +#endif where memId = groupMemberId' member updateQuery = @@ -2887,7 +2893,7 @@ getGroupCIMentions db ciId = SELECT r.display_name, r.member_id, m.group_member_id, m.member_role, p.display_name, p.local_alias FROM chat_item_mentions r LEFT JOIN group_members m ON r.group_id = m.group_id AND r.member_id = m.member_id - LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) WHERE r.chat_item_id = ? |] (Only ciId) 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 88c6c33b41..1f16e0fdff 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3186,7 +3186,7 @@ Query: SELECT r.display_name, r.member_id, m.group_member_id, m.member_role, p.display_name, p.local_alias FROM chat_item_mentions r LEFT JOIN group_members m ON r.group_id = m.group_id AND r.member_id = m.member_id - LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) WHERE r.chat_item_id = ? Plan: diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 73a8735f63..431f19c77f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -84,7 +84,10 @@ chatGroupTests = do describe "batch send messages" $ do it "send multiple messages api" testSendMulti it "send multiple timed messages" testSendMultiTimed +#if !defined(dbPostgres) + -- TODO [postgres] this test hangs with PostgreSQL it "send multiple messages (many chat batches)" testSendMultiManyBatches +#endif xit'' "shared message body is reused" testSharedMessageBody xit'' "shared batch body is reused" testSharedBatchBody describe "async group connections" $ do @@ -1821,7 +1824,7 @@ testDeleteMemberWithMessages = do cath <## "alice updated group #team:" cath <## "updated group preferences:" - cath <## "Full deletion: on" + cath <## "Full deletion: on" ] threadDelay 750000 bob #> "#team hello" @@ -6496,7 +6499,7 @@ testForwardQuoteMention = bob <## " hello @alice @cath", do cath <# "#team alice!> -> forwarded" - cath <## " hello @alice @cath" + cath <## " hello @alice @cath" ] -- forward mentions alice `send` "@bob <- #team hello" From 6fdd50efb941a8ffce26179397f5a69704dc36f5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 8 Jun 2025 18:28:26 +0100 Subject: [PATCH 66/72] core: 6.3.5.0 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f3691ab1a6..7d66292ef5 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.3.4.2 +version: 6.3.5.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 5f6595dda931563afd736e1eaa182d0e1359e09d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 9 Jun 2025 09:32:32 +0100 Subject: [PATCH 67/72] 6.3.5: ios 280, android 292, desktop 104 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5ebc7f9b4b..0a3a42d774 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -174,8 +174,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.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.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 */; }; @@ -533,8 +533,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.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.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 = ""; }; @@ -692,8 +692,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -778,8 +778,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.4.1-Cm6JGiMgJjo4088oWn41JO.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a */, ); path = Libraries; sourceTree = ""; @@ -1971,7 +1971,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1996,7 +1996,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2021,7 +2021,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2046,7 +2046,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2063,11 +2063,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2083,11 +2083,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2108,7 +2108,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2123,7 +2123,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2145,7 +2145,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2160,7 +2160,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2182,7 +2182,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2208,7 +2208,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2233,7 +2233,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2259,7 +2259,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2284,7 +2284,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2299,7 +2299,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2318,7 +2318,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 277; + CURRENT_PROJECT_VERSION = 280; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2333,7 +2333,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.4; + MARKETING_VERSION = 6.3.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 18add58bcf..bf87553b56 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.3.4 -android.version_code=288 +android.version_name=6.3.5 +android.version_code=292 -desktop.version_name=6.3.4 -desktop.version_code=101 +desktop.version_name=6.3.5 +desktop.version_code=104 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 07abe24e18275695959688aadf9ba40dfaa021cb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 14 Jun 2025 14:17:34 +0100 Subject: [PATCH 68/72] core: make decoding for short link data forward compatible (#5989) * core: make decoding for short link data forward compatible * simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 44305353f2..ccdf35d776 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f44ea0a6d8eec8abf4af177ebeb91629f7d89165 + tag: 6ac7101f4f9ad477d967537a647b06d3b6dff547 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0c29b47a36..1454f17a5b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f44ea0a6d8eec8abf4af177ebeb91629f7d89165" = "1biq1kq33v7hnacbhllry9n5c6dmh9dyqnz8hc5abgsv1z38qb1a"; + "https://github.com/simplex-chat/simplexmq.git"."6ac7101f4f9ad477d967537a647b06d3b6dff547" = "0vh6jsn1sh8v39zldar0g04snijyc0fq2h678rbbmk1pprcnwrr7"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From a593557c2116310f66d7cfb1390e3c4a974d701a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 14 Jun 2025 14:46:08 +0100 Subject: [PATCH 69/72] core: 6.3.6.0 (simplexmq 6.4.0.3.1) --- 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 ccdf35d776..f406b9820e 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 6ac7101f4f9ad477d967537a647b06d3b6dff547 + tag: 3d62a383d5dcae6529d6d866233857182bcb4d47 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1454f17a5b..84f9d0db34 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."6ac7101f4f9ad477d967537a647b06d3b6dff547" = "0vh6jsn1sh8v39zldar0g04snijyc0fq2h678rbbmk1pprcnwrr7"; + "https://github.com/simplex-chat/simplexmq.git"."3d62a383d5dcae6529d6d866233857182bcb4d47" = "133xm8jkim7agd6drwm3lbx1z7v8nf4l3asrm46ag3n2q201yfxc"; "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 7d66292ef5..96b2f941c7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.3.5.0 +version: 6.3.6.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 442d9afc4b3c1b6c8a165bbd2ab51bb1000f0087 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 14 Jun 2025 19:26:46 +0100 Subject: [PATCH 70/72] android: remove Contribute link from Android bundle --- apps/multiplatform/common/build.gradle.kts | 1 + .../chat/simplex/common/views/usersettings/SettingsView.kt | 5 ++++- apps/multiplatform/gradle.properties | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 345a75b1e7..e2927e4aaf 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -155,6 +155,7 @@ buildConfig { buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") + buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") } } 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 5bd45ccaab..7ea656e1e4 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 @@ -21,6 +21,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* @@ -127,7 +128,9 @@ fun SettingsLayout( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_support)) { - ContributeItem(uriHandler) + if (!BuildConfigCommon.ANDROID_BUNDLE) { + ContributeItem(uriHandler) + } RateAppItem(uriHandler) StarOnGithubItem(uriHandler) } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index bf87553b56..de82f8272f 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -27,6 +27,8 @@ kotlin.jvm.target=11 android.version_name=6.3.5 android.version_code=292 +android.bundle=false + desktop.version_name=6.3.5 desktop.version_code=104 From c08189108e1616c820606a3050f87bdebfd07a1e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 14 Jun 2025 20:12:19 +0100 Subject: [PATCH 71/72] 6.3.6: ios 282, android 295, desktop 106 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0a3a42d774..9326ae9abe 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -174,8 +174,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.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.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 */; }; @@ -533,8 +533,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.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.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 = ""; }; @@ -692,8 +692,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -778,8 +778,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.5.0-34ngpCJsgXpChvTPhV0df0.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.3.6.0-64eNxtIoLF9BaOhAoPagss.a */, ); path = Libraries; sourceTree = ""; @@ -1971,7 +1971,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1996,7 +1996,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2021,7 +2021,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2046,7 +2046,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2063,11 +2063,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2083,11 +2083,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2108,7 +2108,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2123,7 +2123,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2145,7 +2145,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2160,7 +2160,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2182,7 +2182,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2208,7 +2208,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2233,7 +2233,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2259,7 +2259,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2284,7 +2284,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2299,7 +2299,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2318,7 +2318,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 280; + CURRENT_PROJECT_VERSION = 282; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2333,7 +2333,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.3.5; + MARKETING_VERSION = 6.3.6; 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 de82f8272f..aa4c7a7470 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.3.5 -android.version_code=292 +android.version_name=6.3.6 +android.version_code=295 android.bundle=false -desktop.version_name=6.3.5 -desktop.version_code=104 +desktop.version_name=6.3.6 +desktop.version_code=106 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 3d22b738d8da3f89c5f8f91130ca0e2d62646a1a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 16 Jun 2025 21:38:02 +0000 Subject: [PATCH 72/72] core: fix change connection user (#5992) * core: fix change connection user * plans --- src/Simplex/Chat/Library/Commands.hs | 17 +-------- src/Simplex/Chat/Store/Direct.hs | 14 ------- .../SQLite/Migrations/agent_query_plans.txt | 4 -- .../SQLite/Migrations/chat_query_plans.txt | 8 ---- src/Simplex/Chat/View.hs | 7 ++-- tests/ChatTests/Profiles.hs | 38 +++++++++++++++---- 6 files changed, 34 insertions(+), 54 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d3b945af4f..8c475b111b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1671,25 +1671,10 @@ processChatCommand' vr = \case case (pccConnStatus, connLinkInv) of (ConnNew, Just (CCLink cReqInv _)) -> do newUser <- privateGetUser newUserId - conn' <- ifM (canKeepLink cReqInv newUser) (updateConnRecord user conn newUser) (recreateConn user conn newUser) + conn' <- recreateConn user conn newUser pure $ CRConnectionUserChanged user conn conn' newUser _ -> throwChatError CEConnectionUserChangeProhibited where - canKeepLink :: ConnReqInvitation -> User -> CM Bool - canKeepLink (CRInvitationUri crData _) newUser = do - let ConnReqUriData {crSmpQueues = q :| _} = crData - SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q - newUserServers <- - map protoServer' . L.filter (\ServerCfg {enabled} -> enabled) - <$> getKnownAgentServers SPSMP newUser - pure $ smpServer `elem` newUserServers - updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do - withAgent $ \a -> changeConnectionUser a (aUserId user) (aConnId' conn) (aUserId newUser) - withFastStore' $ \db -> do - conn' <- updatePCCUser db userId conn newUserId - forM_ customUserProfileId $ \profileId -> - deletePCCIncognitoProfile db user profileId - pure conn' recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode let userData = shortLinkUserData $ isJust $ connShortLink =<< connLinkInv diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 4de832a8b1..9318f62f76 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -66,7 +66,6 @@ module Simplex.Chat.Store.Direct updateContactAccepted, getUserByContactRequestId, getPendingContactConnections, - updatePCCUser, getContactConnections, getConnectionById, getConnectionsContacts, @@ -440,19 +439,6 @@ updatePCCIncognito db User {userId} conn customUserProfileId = do (customUserProfileId, updatedAt, userId, pccConnId conn) pure (conn :: PendingContactConnection) {customUserProfileId, updatedAt} -updatePCCUser :: DB.Connection -> UserId -> PendingContactConnection -> UserId -> IO PendingContactConnection -updatePCCUser db userId conn newUserId = do - updatedAt <- getCurrentTime - DB.execute - db - [sql| - UPDATE connections - SET user_id = ?, custom_user_profile_id = NULL, updated_at = ? - WHERE user_id = ? AND connection_id = ? - |] - (newUserId, updatedAt, userId, pccConnId conn) - pure (conn :: PendingContactConnection) {customUserProfileId = Nothing, updatedAt} - deletePCCIncognitoProfile :: DB.Connection -> User -> ProfileId -> IO () deletePCCIncognitoProfile db User {userId} profileId = DB.execute 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 13215dcb75..a85ba4a4cb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1071,10 +1071,6 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) -Query: UPDATE connections SET user_id = ? WHERE conn_id = ? and user_id = ? -Plan: -SEARCH connections USING PRIMARY KEY (conn_id=?) - Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? Plan: SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) 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 1f16e0fdff..e9ade30f93 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4215,14 +4215,6 @@ Query: Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE connections - SET user_id = ?, custom_user_profile_id = NULL, updated_at = ? - WHERE user_id = ? AND connection_id = ? - -Plan: -SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contact_profiles SET contact_link = ?, updated_at = ? diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 42d1132961..4ba5acbb43 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1747,10 +1747,9 @@ viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserPr | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] viewConnectionUserChanged :: User -> PendingContactConnection -> User -> PendingContactConnection -> [StyledString] -viewConnectionUserChanged User {localDisplayName = n} PendingContactConnection {pccConnId, connLinkInv} User {localDisplayName = n'} PendingContactConnection {connLinkInv = connLinkInv'} = - case (connLinkInv, connLinkInv') of - (Just ccLink, Just ccLink') - | ccLink /= ccLink' -> [userChangedStr <> ", new link:"] <> newLink ccLink' +viewConnectionUserChanged User {localDisplayName = n} PendingContactConnection {pccConnId} User {localDisplayName = n'} PendingContactConnection {connLinkInv = connLinkInv'} = + case connLinkInv' of + Just ccLink' -> [userChangedStr <> ", new link:"] <> newLink ccLink' _ -> [userChangedStr] where userChangedStr = "connection " <> sShow pccConnId <> " changed from user " <> plain n <> " to user " <> plain n' diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 433615e62a..adff745200 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1827,7 +1827,7 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite alice ##> "/connect" - inv <- getInvitation alice + _ <- getInvitation alice -- Create new user and go back to original user alice ##> "/create user alisa" showActiveUser alice "alisa" @@ -1837,12 +1837,18 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ showActiveUser alice "alice (Alice)" -- Change connection to newly created user alice ##> "/_set conn user :1 2" - alice <## "connection 1 changed from user alice to user alisa" + alice <## "connection 1 changed from user alice to user alisa, new link:" + alice <## "" + _ <- getTermLine alice + alice <## "" alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection back to other user alice ##> "/_set conn user :1 3" - alice <## "connection 1 changed from user alisa to user alisa2" + alice <## "connection 1 changed from user alisa to user alisa2, new link:" + alice <## "" + inv <- getTermLine alice + alice <## "" alice ##> "/user alisa2" showActiveUser alice "alisa2" -- Connect @@ -1851,13 +1857,14 @@ testChangePCCUser = testChat2 aliceProfile bobProfile $ concurrently_ (alice <## "bob (Bob): contact is connected") (bob <## "alisa2: contact is connected") + alice <##> bob testChangePCCUserFromIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito alice ##> "/connect" - inv <- getInvitation alice + _ <- getInvitation alice alice ##> "/_set incognito :1 on" alice <## "connection 1 changed to incognito" -- Create new user and go back to original user @@ -1867,13 +1874,19 @@ testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ showActiveUser alice "alice (Alice)" -- Change connection to newly created user alice ##> "/_set conn user :1 2" - alice <## "connection 1 changed from user alice to user alisa" + alice <## "connection 1 changed from user alice to user alisa, new link:" + alice <## "" + _ <- getTermLine alice + alice <## "" alice `hasContactProfiles` ["alice"] alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection back to initial user alice ##> "/_set conn user :1 1" - alice <## "connection 1 changed from user alisa to user alice" + alice <## "connection 1 changed from user alisa to user alice, new link:" + alice <## "" + inv <- getTermLine alice + alice <## "" alice ##> "/user alice" showActiveUser alice "alice (Alice)" -- Connect @@ -1882,13 +1895,14 @@ testChangePCCUserFromIncognito = testChat2 aliceProfile bobProfile $ concurrently_ (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") + alice <##> bob testChangePCCUserAndThenIncognito :: HasCallStack => TestParams -> IO () testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- Create a new invite and set as incognito alice ##> "/connect" - inv <- getInvitation alice + _ <- getInvitation alice -- Create new user and go back to original user alice ##> "/create user alisa" showActiveUser alice "alisa" @@ -1896,7 +1910,10 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ showActiveUser alice "alice (Alice)" -- Change connection to newly created user alice ##> "/_set conn user :1 2" - alice <## "connection 1 changed from user alice to user alisa" + alice <## "connection 1 changed from user alice to user alisa, new link:" + alice <## "" + inv <- getTermLine alice + alice <## "" alice ##> "/user alisa" showActiveUser alice "alisa" -- Change connection to incognito and make sure it's attached to the newly created user profile @@ -1911,6 +1928,10 @@ testChangePCCUserAndThenIncognito = testChat2 aliceProfile bobProfile $ alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> alisaIncognito) alice <## ("use /i bob to print out this incognito profile again") ] + alice ?#> "@bob hi" + bob <# (alisaIncognito <> "> hi") + bob #> ("@" <> alisaIncognito <> " hey") + alice ?<# "bob> hey" testChangePCCUserDiffSrv :: HasCallStack => TestParams -> IO () testChangePCCUserDiffSrv ps = do @@ -1951,6 +1972,7 @@ testChangePCCUserDiffSrv ps = do concurrently_ (alice <## "bob (Bob): contact is connected") (bob <## "alisa: contact is connected") + alice <##> bob where serverCfg' = smpServerCfg